diff --git a/.gitignore b/.gitignore index db07950a9..8e86f82df 100644 --- a/.gitignore +++ b/.gitignore @@ -41,4 +41,7 @@ bin tests out build/ -gradle/ \ No newline at end of file +gradle/ + +# Secrets as resources +**/*/res/values/secrets.xml \ No newline at end of file diff --git a/Awful.apk/build.gradle b/Awful.apk/build.gradle index b7ee81178..03167ee2e 100644 --- a/Awful.apk/build.gradle +++ b/Awful.apk/build.gradle @@ -1,36 +1,39 @@ buildscript { + ext.kotlin_version = '1.1.51' + repositories { jcenter() maven { url 'https://maven.fabric.io/public' } + maven { url 'https://maven.google.com' } } dependencies { - classpath 'com.android.tools.build:gradle:2.2.3' + classpath 'com.android.tools.build:gradle:3.0.0' + classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" classpath 'io.fabric.tools:gradle:1.+' } } + apply plugin: 'com.android.application' +apply plugin: 'kotlin-android' apply plugin: 'io.fabric' repositories { - mavenCentral() + jcenter() + maven { url 'https://maven.google.com' } maven { url 'https://maven.fabric.io/public' } maven { url 'https://jitpack.io' } } android { - compileSdkVersion 25 - buildToolsVersion "25.0.2" + compileSdkVersion 26 + buildToolsVersion '26.0.2' defaultConfig { applicationId = "com.ferg.awfulapp" minSdkVersion 15 - targetSdkVersion 25 + targetSdkVersion 26 resConfigs "en" - jackOptions { - enabled true - } - // Stops the Gradle plugin’s automatic rasterization of vectors vectorDrawables.useSupportLibrary = true @@ -78,25 +81,32 @@ android { } compileOptions { - sourceCompatibility JavaVersion.VERSION_1_8 - targetCompatibility JavaVersion.VERSION_1_8 + sourceCompatibility 1.8 + targetCompatibility 1.8 } } dependencies { - compile 'com.android.support:appcompat-v7:25.3.1' - compile 'com.android.support:design:25.3.1' + compile "org.jetbrains.kotlin:kotlin-stdlib-jre7:$kotlin_version" + + compile 'com.android.support:appcompat-v7:26.1.0' + compile 'com.android.support:design:26.1.0' // used to fix SSL issues on older devices - old version so it works on the API 18 emulator THANKS GOOGLE compile 'com.google.android.gms:play-services-auth:9.2.1' //compile 'com.mcxiaoke.volley:library:1.0.19' compile 'com.github.samkirton:android-volley:9aba4f5f86' compile 'com.google.code.gson:gson:2.8.0' - compile 'org.jsoup:jsoup:1.10.2' + compile 'org.jsoup:jsoup:1.10.3' compile 'com.jakewharton.threetenabp:threetenabp:1.0.4' compile 'com.samskivert:jmustache:1.13' - compile 'org.apache.httpcomponents:httpmime:4.3.1' + + compile group: 'cz.msebera.android' , name: 'httpclient', version: '4.4.1.1' + compile ('org.apache.httpcomponents:httpmime:4.3.1') { + exclude module: "httpclient" + } compile 'org.apache.httpcomponents:httpcore:4.3.1' + compile 'org.apache.commons:commons-lang3:3.4' compile 'com.ToxicBakery.viewpager.transforms:view-pager-transforms:1.2.32@aar' compile 'com.github.orangegangsters:swipy:1.2.3@aar' @@ -113,4 +123,5 @@ dependencies { testCompile 'junit:junit:4.12' testCompile 'org.hamcrest:hamcrest-library:1.3' + compile 'com.android.support.constraint:constraint-layout:1.0.2' } diff --git a/Awful.apk/src/main/AndroidManifest.xml b/Awful.apk/src/main/AndroidManifest.xml index 611f4d90f..141c0b388 100644 --- a/Awful.apk/src/main/AndroidManifest.xml +++ b/Awful.apk/src/main/AndroidManifest.xml @@ -1,8 +1,8 @@ @@ -110,16 +109,6 @@ - - ').attr('src', 'https://platform.instagram.com/en_US/embeds.js').appendTo('head') + // potential race condition here if the promises complete before the script loads, so the function isn't available yet + $.when.apply($, promises).then(function() { instgrm.Embeds.process() }); + } + +} \ No newline at end of file diff --git a/Awful.apk/src/main/assets/javascript/thread.js b/Awful.apk/src/main/assets/javascript/thread.js index 3f2545e9e..d4c5396e6 100644 --- a/Awful.apk/src/main/assets/javascript/thread.js +++ b/Awful.apk/src/main/assets/javascript/thread.js @@ -121,6 +121,8 @@ function pageInit() { }); } + processThreadEmbeds(); + $('.postcontent').find('div.bbcode_video object param[value^="http://vimeo.com"]').each(function(){ var videoID = $(this).attr('value').match(/clip_id=(\d+)/) if (videoID === null) return diff --git a/Awful.apk/src/main/assets/javascript/zepto/callbacks.js b/Awful.apk/src/main/assets/javascript/zepto/callbacks.js new file mode 100644 index 000000000..4d467250d --- /dev/null +++ b/Awful.apk/src/main/assets/javascript/zepto/callbacks.js @@ -0,0 +1,122 @@ +// Zepto.js +// (c) 2010-2016 Thomas Fuchs +// Zepto.js may be freely distributed under the MIT license. + +;(function($){ + // Create a collection of callbacks to be fired in a sequence, with configurable behaviour + // Option flags: + // - once: Callbacks fired at most one time. + // - memory: Remember the most recent context and arguments + // - stopOnFalse: Cease iterating over callback list + // - unique: Permit adding at most one instance of the same callback + $.Callbacks = function(options) { + options = $.extend({}, options) + + var memory, // Last fire value (for non-forgettable lists) + fired, // Flag to know if list was already fired + firing, // Flag to know if list is currently firing + firingStart, // First callback to fire (used internally by add and fireWith) + firingLength, // End of the loop when firing + firingIndex, // Index of currently firing callback (modified by remove if needed) + list = [], // Actual callback list + stack = !options.once && [], // Stack of fire calls for repeatable lists + fire = function(data) { + memory = options.memory && data + fired = true + firingIndex = firingStart || 0 + firingStart = 0 + firingLength = list.length + firing = true + for ( ; list && firingIndex < firingLength ; ++firingIndex ) { + if (list[firingIndex].apply(data[0], data[1]) === false && options.stopOnFalse) { + memory = false + break + } + } + firing = false + if (list) { + if (stack) stack.length && fire(stack.shift()) + else if (memory) list.length = 0 + else Callbacks.disable() + } + }, + + Callbacks = { + add: function() { + if (list) { + var start = list.length, + add = function(args) { + $.each(args, function(_, arg){ + if (typeof arg === "function") { + if (!options.unique || !Callbacks.has(arg)) list.push(arg) + } + else if (arg && arg.length && typeof arg !== 'string') add(arg) + }) + } + add(arguments) + if (firing) firingLength = list.length + else if (memory) { + firingStart = start + fire(memory) + } + } + return this + }, + remove: function() { + if (list) { + $.each(arguments, function(_, arg){ + var index + while ((index = $.inArray(arg, list, index)) > -1) { + list.splice(index, 1) + // Handle firing indexes + if (firing) { + if (index <= firingLength) --firingLength + if (index <= firingIndex) --firingIndex + } + } + }) + } + return this + }, + has: function(fn) { + return !!(list && (fn ? $.inArray(fn, list) > -1 : list.length)) + }, + empty: function() { + firingLength = list.length = 0 + return this + }, + disable: function() { + list = stack = memory = undefined + return this + }, + disabled: function() { + return !list + }, + lock: function() { + stack = undefined + if (!memory) Callbacks.disable() + return this + }, + locked: function() { + return !stack + }, + fireWith: function(context, args) { + if (list && (!fired || stack)) { + args = args || [] + args = [context, args.slice ? args.slice() : args] + if (firing) stack.push(args) + else fire(args) + } + return this + }, + fire: function() { + return Callbacks.fireWith(this, arguments) + }, + fired: function() { + return !!fired + } + } + + return Callbacks + } +})(Zepto) \ No newline at end of file diff --git a/Awful.apk/src/main/assets/javascript/zepto/deferred.js b/Awful.apk/src/main/assets/javascript/zepto/deferred.js new file mode 100644 index 000000000..065aeaa84 --- /dev/null +++ b/Awful.apk/src/main/assets/javascript/zepto/deferred.js @@ -0,0 +1,118 @@ +// Zepto.js +// (c) 2010-2016 Thomas Fuchs +// Zepto.js may be freely distributed under the MIT license. +// +// Some code (c) 2005, 2013 jQuery Foundation, Inc. and other contributors + +;(function($){ + var slice = Array.prototype.slice + + function Deferred(func) { + var tuples = [ + // action, add listener, listener list, final state + [ "resolve", "done", $.Callbacks({once:1, memory:1}), "resolved" ], + [ "reject", "fail", $.Callbacks({once:1, memory:1}), "rejected" ], + [ "notify", "progress", $.Callbacks({memory:1}) ] + ], + state = "pending", + promise = { + state: function() { + return state + }, + always: function() { + deferred.done(arguments).fail(arguments) + return this + }, + then: function(/* fnDone [, fnFailed [, fnProgress]] */) { + var fns = arguments + return Deferred(function(defer){ + $.each(tuples, function(i, tuple){ + var fn = $.isFunction(fns[i]) && fns[i] + deferred[tuple[1]](function(){ + var returned = fn && fn.apply(this, arguments) + if (returned && $.isFunction(returned.promise)) { + returned.promise() + .done(defer.resolve) + .fail(defer.reject) + .progress(defer.notify) + } else { + var context = this === promise ? defer.promise() : this, + values = fn ? [returned] : arguments + defer[tuple[0] + "With"](context, values) + } + }) + }) + fns = null + }).promise() + }, + + promise: function(obj) { + return obj != null ? $.extend( obj, promise ) : promise + } + }, + deferred = {} + + $.each(tuples, function(i, tuple){ + var list = tuple[2], + stateString = tuple[3] + + promise[tuple[1]] = list.add + + if (stateString) { + list.add(function(){ + state = stateString + }, tuples[i^1][2].disable, tuples[2][2].lock) + } + + deferred[tuple[0]] = function(){ + deferred[tuple[0] + "With"](this === deferred ? promise : this, arguments) + return this + } + deferred[tuple[0] + "With"] = list.fireWith + }) + + promise.promise(deferred) + if (func) func.call(deferred, deferred) + return deferred + } + + $.when = function(sub) { + var resolveValues = slice.call(arguments), + len = resolveValues.length, + i = 0, + remain = len !== 1 || (sub && $.isFunction(sub.promise)) ? len : 0, + deferred = remain === 1 ? sub : Deferred(), + progressValues, progressContexts, resolveContexts, + updateFn = function(i, ctx, val){ + return function(value){ + ctx[i] = this + val[i] = arguments.length > 1 ? slice.call(arguments) : value + if (val === progressValues) { + deferred.notifyWith(ctx, val) + } else if (!(--remain)) { + deferred.resolveWith(ctx, val) + } + } + } + + if (len > 1) { + progressValues = new Array(len) + progressContexts = new Array(len) + resolveContexts = new Array(len) + for ( ; i < len; ++i ) { + if (resolveValues[i] && $.isFunction(resolveValues[i].promise)) { + resolveValues[i].promise() + .done(updateFn(i, resolveContexts, resolveValues)) + .fail(deferred.reject) + .progress(updateFn(i, progressContexts, progressValues)) + } else { + --remain + } + } + } + if (!remain) deferred.resolveWith(resolveContexts, resolveValues) + return deferred.promise() + } + + $.Deferred = Deferred +})(Zepto) \ No newline at end of file diff --git a/Awful.apk/src/main/ic_launcher-web.png b/Awful.apk/src/main/ic_launcher-web.png new file mode 100644 index 000000000..9ee1fe356 Binary files /dev/null and b/Awful.apk/src/main/ic_launcher-web.png differ diff --git a/Awful.apk/src/main/java/com/ferg/awfulapp/BasicActivity.kt b/Awful.apk/src/main/java/com/ferg/awfulapp/BasicActivity.kt new file mode 100644 index 000000000..7bf01d319 --- /dev/null +++ b/Awful.apk/src/main/java/com/ferg/awfulapp/BasicActivity.kt @@ -0,0 +1,58 @@ +package com.ferg.awfulapp + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import android.support.v4.app.Fragment +import android.support.v7.widget.Toolbar +import android.view.MenuItem +import android.view.View +import com.ferg.awfulapp.BasicActivity.Companion.intentFor + +/** + * Created by baka kaba on 31/07/2017. + * + * An AwfulActivity with a basic standard configuration, with a working Action Bar and a single fragment. + * + * This really exists to avoid adding multiple activities to the project when you just want to + * host a fragment with the usual toolbar setup. Call [intentFor] to generate the appropriate intent + * for a particular fragment, then you can call [startActivity] as usual. + */ + + +class BasicActivity: AwfulActivity() { + + companion object { + private const val FRAGMENT_CLASS: String = "fragment class" + private const val TITLE: String = "action bar title" + + fun intentFor(fragmentClass: Class, context: Context, title: String = ""): Intent = + Intent(context, BasicActivity::class.java) + .putExtra(FRAGMENT_CLASS, fragmentClass.name) + .putExtra(TITLE, title) + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.basic_activity) + + val fragmentName = intent.extras.getString(FRAGMENT_CLASS) ?: throw RuntimeException("No content fragment specified!") + val fragment = Class.forName(fragmentName).newInstance() as Fragment + supportFragmentManager + .beginTransaction() + .add(R.id.content_frame, fragment, fragmentName) + .commit() + + setSupportActionBar(findViewById(R.id.toolbar) as Toolbar) + setActionBar() + setActionbarTitle(intent.extras.getString(TITLE, "No title"), null) + } + + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + when (item.itemId) { + android.R.id.home -> { finish(); return true } + } + return super.onOptionsItemSelected(item) + } +} \ No newline at end of file diff --git a/Awful.apk/src/main/java/com/ferg/awfulapp/ForumsIndexActivity.java b/Awful.apk/src/main/java/com/ferg/awfulapp/ForumsIndexActivity.java index a8f34a4b1..17e1c7ecf 100644 --- a/Awful.apk/src/main/java/com/ferg/awfulapp/ForumsIndexActivity.java +++ b/Awful.apk/src/main/java/com/ferg/awfulapp/ForumsIndexActivity.java @@ -121,6 +121,10 @@ public class ForumsIndexActivity extends AwfulActivity private static final int NULL_THREAD_ID = 0; private static final int NULL_PAGE_ID = -1; + private static final int FORUM_LIST_FRAGMENT_POSITION = 0; + private static final int THREAD_LIST_FRAGMENT_POSITION = 1; + private static final int THREAD_VIEW_FRAGMENT_POSITION = 2; + private volatile int mNavForumId = Constants.USERCP_ID; private volatile int mNavThreadId = NULL_THREAD_ID; private volatile int mForumId = Constants.USERCP_ID; @@ -307,7 +311,8 @@ public boolean onNavigationItemSelected (MenuItem menuItem){ startActivity(new Intent().setClass(context, SettingsActivity.class)); break; case R.id.sidebar_search: - startActivity(new Intent().setClass(context, SearchActivity.class)); + Intent intent = BasicActivity.Companion.intentFor(SearchFragment.class, context, ""); + startActivity(intent); break; case R.id.sidebar_pm: startActivity(new Intent().setClass(context, PrivateMessageActivity.class)); @@ -366,14 +371,14 @@ public void onResponse(ImageLoader.ImageContainer response, boolean isImmediate) @Override public void onErrorResponse(VolleyError error) { - avatar.setImageResource(R.mipmap.ic_launcher); + avatar.setImageResource(R.drawable.frog_icon); } }); if (AwfulUtils.isLollipop()) { avatar.setClipToOutline(true); } } else { - avatar.setImageResource(R.mipmap.ic_launcher); + avatar.setImageResource(R.drawable.frog_icon); if (AwfulUtils.isLollipop()) { avatar.setClipToOutline(false); } @@ -730,8 +735,11 @@ public void onPageSelected(int arg0) { if (visible != null) { visible.onPageHidden(); } - AwfulFragment apf = (AwfulFragment) getItem(arg0); - if (apf != null) { + AwfulFragment apf = (AwfulFragment) instantiateItem(mViewPager, arg0); + // I don't know if #isAdded is necessary after calling #instantiateItem (instead of #getItem + // which just creates a new fragment object), but I'm trying to fix a bug I can't reproduce + // where these fragment methods crash because they have no activity yet + if (apf != null && apf.isAdded()) { setActionbarTitle(apf.getTitle(), null); apf.onPageVisible(); setProgress(apf.getProgressPercent()); @@ -748,23 +756,14 @@ public void onPageScrollStateChanged(int state) { } @Override - public Fragment getItem(int ix) { - switch (ix) { - case 0: - if (mIndexFragment == null) { - mIndexFragment = new ForumsIndexFragment(); - } - return mIndexFragment; - case 1: - if (mForumFragment == null) { - mForumFragment = ForumDisplayFragment.getInstance(mForumId, mForumPage, skipLoad); - } - return mForumFragment; - case 2: - if (mThreadFragment == null) { - mThreadFragment = new ThreadDisplayFragment(); - } - return mThreadFragment; + public Fragment getItem(int position) { + switch (position) { + case FORUM_LIST_FRAGMENT_POSITION: + return new ForumsIndexFragment(); + case THREAD_LIST_FRAGMENT_POSITION: + return ForumDisplayFragment.getInstance(mForumId, mForumPage, skipLoad); + case THREAD_VIEW_FRAGMENT_POSITION: + return new ThreadDisplayFragment(); } Log.e(TAG, "ERROR: asked for too many fragments in ForumPagerAdapter.getItem"); return null; @@ -773,14 +772,16 @@ public Fragment getItem(int ix) { @Override public Object instantiateItem(ViewGroup container, int position) { Object frag = super.instantiateItem(container, position); - if (frag instanceof ForumsIndexFragment) { - mIndexFragment = (ForumsIndexFragment) frag; - } - if (frag instanceof ForumDisplayFragment) { - mForumFragment = (ForumDisplayFragment) frag; - } - if (frag instanceof ThreadDisplayFragment) { - mThreadFragment = (ThreadDisplayFragment) frag; + switch (position) { + case FORUM_LIST_FRAGMENT_POSITION: + mIndexFragment = (ForumsIndexFragment) frag; + break; + case THREAD_LIST_FRAGMENT_POSITION: + mForumFragment = (ForumDisplayFragment) frag; + break; + case THREAD_VIEW_FRAGMENT_POSITION: + mThreadFragment = (ThreadDisplayFragment) frag; + break; } return frag; } @@ -796,13 +797,13 @@ public int getCount() { @Override public int getItemPosition(Object object) { if (mIndexFragment != null && mIndexFragment.equals(object)) { - return 0; + return FORUM_LIST_FRAGMENT_POSITION; } if (mForumFragment != null && mForumFragment.equals(object)) { - return 1; + return THREAD_LIST_FRAGMENT_POSITION; } if (mThreadFragment != null && mThreadFragment.equals(object)) { - return 2; + return THREAD_VIEW_FRAGMENT_POSITION; } return super.getItemPosition(object); } @@ -811,11 +812,11 @@ public int getItemPosition(Object object) { public float getPageWidth(int position) { if (isTablet) { switch (position) { - case 0: + case FORUM_LIST_FRAGMENT_POSITION: return 0.4f; - case 1: + case THREAD_LIST_FRAGMENT_POSITION: return 0.6f; - case 2: + case THREAD_VIEW_FRAGMENT_POSITION: return 1f; } } diff --git a/Awful.apk/src/main/java/com/ferg/awfulapp/ForumsIndexFragment.java b/Awful.apk/src/main/java/com/ferg/awfulapp/ForumsIndexFragment.java index 87f226483..871fdfd30 100644 --- a/Awful.apk/src/main/java/com/ferg/awfulapp/ForumsIndexFragment.java +++ b/Awful.apk/src/main/java/com/ferg/awfulapp/ForumsIndexFragment.java @@ -15,8 +15,6 @@ import android.view.View; import android.view.ViewGroup; import android.view.animation.AnimationUtils; -import android.widget.ProgressBar; -import android.widget.TextView; import android.widget.ViewSwitcher; import com.ferg.awfulapp.forums.Forum; @@ -25,6 +23,7 @@ import com.ferg.awfulapp.preferences.AwfulPreferences; import com.ferg.awfulapp.preferences.Keys; import com.ferg.awfulapp.provider.ColorProvider; +import com.ferg.awfulapp.widget.StatusFrog; import java.util.ArrayList; import java.util.List; @@ -32,8 +31,6 @@ import butterknife.BindView; import butterknife.ButterKnife; -import static android.view.View.INVISIBLE; -import static android.view.View.VISIBLE; import static com.ferg.awfulapp.forums.ForumStructure.FLAT; import static com.ferg.awfulapp.forums.ForumStructure.TWO_LEVEL; @@ -61,10 +58,8 @@ public class ForumsIndexFragment extends AwfulFragment RecyclerView forumRecyclerView; @BindView(R.id.view_switcher) ViewSwitcher forumsListSwitcher; - @BindView(R.id.forums_update_progress_bar) - ProgressBar updatingIndicator; - @BindView(R.id.no_forums_label) - TextView noForumsLabel; + @BindView(R.id.status_frog) + StatusFrog statusFrog; private ForumListAdapter forumListAdapter; private ForumRepository forumRepo; @@ -190,7 +185,7 @@ private void refreshForumList() { */ private void refreshNoDataView() { // adjust the label in the 'no forums' view - noForumsLabel.setText(showFavourites ? R.string.no_favourites : R.string.no_forums_data); + statusFrog.setStatusText(showFavourites ? R.string.no_favourites : R.string.no_forums_data); // work out if we need to switch the empty view to the forum list, or vice versa boolean noData = forumListAdapter.getParentItemList().isEmpty(); @@ -200,7 +195,7 @@ private void refreshNoDataView() { forumsListSwitcher.showNext(); } // show the update spinner if an update is going on - updatingIndicator.setVisibility(forumRepo.isUpdating() ? VISIBLE : INVISIBLE); + statusFrog.showSpinner(forumRepo.isUpdating()); } @@ -246,7 +241,7 @@ public void onContextMenuCreated(@NonNull Forum forum, @NonNull Menu contextMenu @Override public void onForumsUpdateStarted() { - getActivity().runOnUiThread(() -> updatingIndicator.setVisibility(VISIBLE)); + getActivity().runOnUiThread(() -> statusFrog.showSpinner(true)); } @@ -257,14 +252,14 @@ public void onForumsUpdateCompleted(final boolean success) { Snackbar.make(forumRecyclerView, R.string.forums_updated_message, Snackbar.LENGTH_SHORT).show(); refreshForumList(); } - updatingIndicator.setVisibility(INVISIBLE); + statusFrog.showSpinner(false); }); } @Override public void onForumsUpdateCancelled() { - getActivity().runOnUiThread(() -> updatingIndicator.setVisibility(INVISIBLE)); + getActivity().runOnUiThread(() -> statusFrog.showSpinner(false)); } diff --git a/Awful.apk/src/main/java/com/ferg/awfulapp/ImageViewActivity.java b/Awful.apk/src/main/java/com/ferg/awfulapp/ImageViewActivity.java deleted file mode 100644 index 1b6afb7a1..000000000 --- a/Awful.apk/src/main/java/com/ferg/awfulapp/ImageViewActivity.java +++ /dev/null @@ -1,87 +0,0 @@ -/******************************************************************************** - * Copyright (c) 2011, Scott Ferguson - * All rights reserved. - * - * Redistribution and use in source and binary forms, with or without - * modification, are permitted provided that the following conditions are met: - * * Redistributions of source code must retain the above copyright - * notice, this list of conditions and the following disclaimer. - * * Redistributions in binary form must reproduce the above copyright - * notice, this list of conditions and the following disclaimer in the - * documentation and/or other materials provided with the distribution. - * * Neither the name of the software nor the - * names of its contributors may be used to endorse or promote products - * derived from this software without specific prior written permission. - * - * THIS SOFTWARE IS PROVIDED BY SCOTT FERGUSON ''AS IS'' AND ANY - * EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED - * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE - * DISCLAIMED. IN NO EVENT SHALL SCOTT FERGUSON BE LIABLE FOR ANY - * DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES - * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; - * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND - * ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT - * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS - * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - *******************************************************************************/ - -package com.ferg.awfulapp; - -import android.content.Intent; -import android.os.Bundle; -import android.support.v7.widget.Toolbar; -import android.view.MenuItem; -import android.widget.ImageView; - -import com.ferg.awfulapp.constants.Constants; -import com.nostra13.universalimageloader.core.ImageLoader; -import com.nostra13.universalimageloader.core.ImageLoaderConfiguration; - -import uk.co.senab.photoview.PhotoViewAttacher; - - -public class ImageViewActivity extends AwfulActivity { - - private Toolbar mToolbar; - private PhotoViewAttacher mAttacher; - - @Override - public void onCreate(Bundle savedInstanceState) - { - super.onCreate(savedInstanceState); - setContentView(R.layout.imageview_activity); - - ImageView mImageView = (ImageView) findViewById(R.id.iv_photo); - mToolbar = (Toolbar) findViewById(R.id.awful_toolbar_imageview); - Intent intent = getIntent(); - String url = intent.getStringExtra(Constants.ZOOM_URL); - - setSupportActionBar(mToolbar); - setActionBar(); - if( url != null){ - ImageLoader imageLoader = ImageLoader.getInstance(); - imageLoader.init(ImageLoaderConfiguration.createDefault(this)); - imageLoader.displayImage(url, mImageView); - setActionbarTitle(url,this); - } - - - } - - @Override - protected void onStart() { - super.onStart(); - } - - @Override - public boolean onOptionsItemSelected(MenuItem item) { - switch (item.getItemId()) { - case android.R.id.home: - finish(); - break; - } - - return super.onOptionsItemSelected(item); - } - -} diff --git a/Awful.apk/src/main/java/com/ferg/awfulapp/ImageViewFragment.kt b/Awful.apk/src/main/java/com/ferg/awfulapp/ImageViewFragment.kt new file mode 100644 index 000000000..ff7cb7c68 --- /dev/null +++ b/Awful.apk/src/main/java/com/ferg/awfulapp/ImageViewFragment.kt @@ -0,0 +1,68 @@ +/******************************************************************************** + * Copyright (c) 2011, Scott Ferguson + * All rights reserved. + + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * * Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * * Redistributions in binary form must reproduce the above copyright + * notice, this list of conditions and the following disclaimer in the + * documentation and/or other materials provided with the distribution. + * * Neither the name of the software nor the + * names of its contributors may be used to endorse or promote products + * derived from this software without specific prior written permission. + + * THIS SOFTWARE IS PROVIDED BY SCOTT FERGUSON ''AS IS'' AND ANY + * EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL SCOTT FERGUSON BE LIABLE FOR ANY + * DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND + * ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +package com.ferg.awfulapp + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.ImageView +import com.nostra13.universalimageloader.core.ImageLoader +import com.nostra13.universalimageloader.core.ImageLoaderConfiguration + +/** + * Loads and displays an image in a zoomable view. + */ +class ImageViewFragment : AwfulFragment() { + + private var imageUrl = "No image url" + + companion object { + const val EXTRA_IMAGE_URL = "image url" + } + + override fun onCreateView(inflater: LayoutInflater?, container: ViewGroup?, savedInstanceState: Bundle?): View? { + return inflateView(R.layout.image_view_fragment, container, inflater) + } + + override fun onActivityCreated(aSavedState: Bundle?) { + super.onActivityCreated(aSavedState) + val mImageView = activity.findViewById(R.id.iv_photo) as ImageView + + activity.intent.getStringExtra(EXTRA_IMAGE_URL)?.let { + imageUrl = it + with(ImageLoader.getInstance()) { + init(ImageLoaderConfiguration.createDefault(activity)) + displayImage(imageUrl, mImageView) + } + } + title = imageUrl + } + + override fun getTitle(): String = imageUrl +} diff --git a/Awful.apk/src/main/java/com/ferg/awfulapp/SearchActivity.java b/Awful.apk/src/main/java/com/ferg/awfulapp/SearchActivity.java deleted file mode 100644 index 13a1915a0..000000000 --- a/Awful.apk/src/main/java/com/ferg/awfulapp/SearchActivity.java +++ /dev/null @@ -1,66 +0,0 @@ -/******************************************************************************** - * Copyright (c) 2011, Scott Ferguson - * All rights reserved. - * - * Redistribution and use in source and binary forms, with or without - * modification, are permitted provided that the following conditions are met: - * * Redistributions of source code must retain the above copyright - * notice, this list of conditions and the following disclaimer. - * * Redistributions in binary form must reproduce the above copyright - * notice, this list of conditions and the following disclaimer in the - * documentation and/or other materials provided with the distribution. - * * Neither the name of the software nor the - * names of its contributors may be used to endorse or promote products - * derived from this software without specific prior written permission. - * - * THIS SOFTWARE IS PROVIDED BY SCOTT FERGUSON ''AS IS'' AND ANY - * EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED - * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE - * DISCLAIMED. IN NO EVENT SHALL SCOTT FERGUSON BE LIABLE FOR ANY - * DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES - * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; - * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND - * ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT - * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS - * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - *******************************************************************************/ - -package com.ferg.awfulapp; - -import android.os.Bundle; -import android.support.v7.widget.Toolbar; -import android.view.MenuItem; - - -public class SearchActivity extends AwfulActivity { - - private Toolbar mToolbar; - - @Override - public void onCreate(Bundle savedInstanceState) - { - super.onCreate(savedInstanceState); - setContentView(R.layout.search_activity); - - mToolbar = (Toolbar) findViewById(R.id.awful_toolbar_search); - setSupportActionBar(mToolbar); - setActionBar(); - } - - @Override - protected void onStart() { - super.onStart(); - } - - @Override - public boolean onOptionsItemSelected(MenuItem item) { - switch (item.getItemId()) { - case android.R.id.home: - finish(); - break; - } - - return super.onOptionsItemSelected(item); - } - -} diff --git a/Awful.apk/src/main/java/com/ferg/awfulapp/ThreadDisplayFragment.java b/Awful.apk/src/main/java/com/ferg/awfulapp/ThreadDisplayFragment.java index 678f65a74..5b5dfbdea 100644 --- a/Awful.apk/src/main/java/com/ferg/awfulapp/ThreadDisplayFragment.java +++ b/Awful.apk/src/main/java/com/ferg/awfulapp/ThreadDisplayFragment.java @@ -1277,11 +1277,10 @@ public void startUrlIntent(String url){ } } - public void displayImage(String text){ - Intent intent = new Intent(this.getContext(),ImageViewActivity.class); - intent.putExtra(Constants.ZOOM_URL, text); + public void displayImage(String url){ + Intent intent = BasicActivity.Companion.intentFor(ImageViewFragment.class, getActivity(), ""); + intent.putExtra(ImageViewFragment.EXTRA_IMAGE_URL, url); startActivity(intent); - } @Override diff --git a/Awful.apk/src/main/java/com/ferg/awfulapp/announcements/AnnouncementsActivity.java b/Awful.apk/src/main/java/com/ferg/awfulapp/announcements/AnnouncementsActivity.java deleted file mode 100644 index 771c761b8..000000000 --- a/Awful.apk/src/main/java/com/ferg/awfulapp/announcements/AnnouncementsActivity.java +++ /dev/null @@ -1,43 +0,0 @@ -package com.ferg.awfulapp.announcements; - -import android.os.Bundle; -import android.view.MenuItem; - -import com.ferg.awfulapp.AwfulActivity; -import com.ferg.awfulapp.R; - -import butterknife.ButterKnife; - -/** - * Created by baka kaba on 24/01/2017. - *

- * Basic activity that displays announcements. - * Doesn't need to exist to be honest, someone make a reusable container activity! - */ - -public class AnnouncementsActivity extends AwfulActivity { - - // TODO: 05/02/2017 this is a toolbar and a fragment, and the fragment is just a webview - refactor that layout into something reusable! - - @Override - protected void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - setContentView(R.layout.announcements_activity); - ButterKnife.bind(this); - setSupportActionBar(ButterKnife.findById(this, R.id.toolbar)); - setActionBar(); - setActionbarTitle(getString(R.string.announcements), null); - } - - - @Override - public boolean onOptionsItemSelected(MenuItem item) { - switch (item.getItemId()) { - case android.R.id.home: - finish(); - break; - } - - return super.onOptionsItemSelected(item); - } -} diff --git a/Awful.apk/src/main/java/com/ferg/awfulapp/announcements/AnnouncementsFragment.java b/Awful.apk/src/main/java/com/ferg/awfulapp/announcements/AnnouncementsFragment.java index 44ec2d145..744466b8f 100644 --- a/Awful.apk/src/main/java/com/ferg/awfulapp/announcements/AnnouncementsFragment.java +++ b/Awful.apk/src/main/java/com/ferg/awfulapp/announcements/AnnouncementsFragment.java @@ -25,6 +25,7 @@ import com.ferg.awfulapp.thread.ThreadDisplay; import com.ferg.awfulapp.webview.AwfulWebView; import com.ferg.awfulapp.webview.WebViewJsInterface; +import com.ferg.awfulapp.widget.StatusFrog; import java.util.List; @@ -38,8 +39,7 @@ *

* This is basically a butchered thread view since that's kind of what the announcements page is. * Most of that happens in the request (not setting certain fields), here we just throw in some - * meaningless constants in the {@link #getHtml(List} - * call, and hope it doesn't break. Seems to work! Fix later! + * meaningless constants in the {@link ThreadDisplay#getHtml} call, and hope it doesn't break. Seems to work! Fix later! *

* Also this also assumes the announcements page won't ever have more than one page. * Whatever it's not even a thread @@ -49,8 +49,9 @@ public class AnnouncementsFragment extends AwfulFragment { @BindView(R.id.announcements_webview) AwfulWebView webView; + @BindView(R.id.status_frog) + StatusFrog statusFrog; - // TODO: 27/01/2017 stick a frog in the background or something while loading private String bodyHtml = ""; @NonNull @@ -133,24 +134,31 @@ public void onActivityCreated(Bundle aSavedState) { */ private void showAnnouncements() { Context context = getContext().getApplicationContext(); - setProgress(25); + statusFrog.setStatusText(R.string.announcements_status_fetching).showSpinner(true); queueRequest( new AnnouncementsRequest(context).build(this, new AwfulRequest.AwfulResultCallback>() { @Override public void success(List result) { AnnouncementsManager.getInstance().markAllRead(); - // these constants don't mean anything in the context of the announcement page - // we just want it to a) display ok, and b) not let the user click anything bad - bodyHtml = ThreadDisplay.getHtml(result, AwfulPreferences.getInstance(), 1, 1); - if (webView != null) { - webView.refreshPageContents(true); + // update the status frog if there are no announcements, otherwise hide it and display them + if (result.size() < 1) { + statusFrog.setStatusText(R.string.announcements_status_none).showSpinner(false); + } else { + webView.setVisibility(View.VISIBLE); + // these page params don't mean anything in the context of the announcement page + // we just want it to a) display ok, and b) not let the user click anything bad + bodyHtml = ThreadDisplay.getHtml(result, AwfulPreferences.getInstance(), 1, 1); + if (webView != null) { + webView.refreshPageContents(true); + } + statusFrog.setVisibility(View.INVISIBLE); } } @Override public void failure(VolleyError error) { + statusFrog.setStatusText(R.string.announcements_status_failed).showSpinner(false); Log.w(TAG, "Announcement get failed!\n" + error.getMessage()); - setProgress(100); } }) ); diff --git a/Awful.apk/src/main/java/com/ferg/awfulapp/announcements/AnnouncementsManager.java b/Awful.apk/src/main/java/com/ferg/awfulapp/announcements/AnnouncementsManager.java index 9b2643254..45ae873fc 100644 --- a/Awful.apk/src/main/java/com/ferg/awfulapp/announcements/AnnouncementsManager.java +++ b/Awful.apk/src/main/java/com/ferg/awfulapp/announcements/AnnouncementsManager.java @@ -13,6 +13,8 @@ import com.android.volley.VolleyError; import com.ferg.awfulapp.AwfulApplication; +import com.ferg.awfulapp.BasicActivity; +import com.ferg.awfulapp.R; import com.ferg.awfulapp.constants.Constants; import com.ferg.awfulapp.network.NetworkUtils; import com.ferg.awfulapp.task.AwfulRequest; @@ -206,7 +208,8 @@ public int getUnreadCount() { * @param activity used to launch the new activity */ public void showAnnouncements(@NonNull Activity activity) { - activity.startActivity(new Intent().setClass(activity.getApplicationContext(), AnnouncementsActivity.class)); + Intent intent = BasicActivity.Companion.intentFor(AnnouncementsFragment.class, activity, activity.getString(R.string.announcements)); + activity.startActivity(intent); } diff --git a/Awful.apk/src/main/java/com/ferg/awfulapp/constants/Constants.java b/Awful.apk/src/main/java/com/ferg/awfulapp/constants/Constants.java index 7fcb6de5e..352486197 100644 --- a/Awful.apk/src/main/java/com/ferg/awfulapp/constants/Constants.java +++ b/Awful.apk/src/main/java/com/ferg/awfulapp/constants/Constants.java @@ -173,8 +173,6 @@ public class Constants { public static final String EXTRA_BUNDLE = "extras"; - public static final String ZOOM_URL = "url"; - public static final String SUBMIT_REPLY = "Submit Reply"; public static final String PREVIEW_REPLY = "Preview Reply"; diff --git a/Awful.apk/src/main/java/com/ferg/awfulapp/popupmenu/BasePopupMenu.java b/Awful.apk/src/main/java/com/ferg/awfulapp/popupmenu/BasePopupMenu.java index 861698b93..7a5cb2039 100644 --- a/Awful.apk/src/main/java/com/ferg/awfulapp/popupmenu/BasePopupMenu.java +++ b/Awful.apk/src/main/java/com/ferg/awfulapp/popupmenu/BasePopupMenu.java @@ -95,6 +95,8 @@ public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle sa /** * Called when the user selects one of your menu items. + * + * The dialog is dismissed after this method is called - don't dismiss it yourself! */ abstract void onActionClicked(@NonNull T action); @@ -139,7 +141,11 @@ public void onBindViewHolder(ActionHolder holder, final int position) { holder.actionText.setText(getMenuLabel(action)); holder.actionText.setTextColor(ColorProvider.PRIMARY_TEXT.getColor()); holder.actionTag.setImageResource(action.getIconId()); - holder.itemView.setOnClickListener(v -> onActionClicked(action)); + holder.itemView.setOnClickListener(v -> { + onActionClicked(action); + // Sometimes this happens after onSaveInstanceState is called, which throws an Exception if we don't allow state loss + dismissAllowingStateLoss(); + }); } @Override diff --git a/Awful.apk/src/main/java/com/ferg/awfulapp/popupmenu/PostContextMenu.java b/Awful.apk/src/main/java/com/ferg/awfulapp/popupmenu/PostContextMenu.java index ef1243de2..d37eb7d39 100644 --- a/Awful.apk/src/main/java/com/ferg/awfulapp/popupmenu/PostContextMenu.java +++ b/Awful.apk/src/main/java/com/ferg/awfulapp/popupmenu/PostContextMenu.java @@ -175,7 +175,6 @@ void onActionClicked(@NonNull PostMenuAction action) { parent.reportUser(postId); break; } - this.dismiss(); } diff --git a/Awful.apk/src/main/java/com/ferg/awfulapp/popupmenu/UrlContextMenu.java b/Awful.apk/src/main/java/com/ferg/awfulapp/popupmenu/UrlContextMenu.java index 5962c7452..ad413f6a1 100644 --- a/Awful.apk/src/main/java/com/ferg/awfulapp/popupmenu/UrlContextMenu.java +++ b/Awful.apk/src/main/java/com/ferg/awfulapp/popupmenu/UrlContextMenu.java @@ -149,7 +149,6 @@ void onActionClicked(@NonNull UrlMenuAction action) { parent.displayImage(url); break; } - this.dismiss(); } diff --git a/Awful.apk/src/main/java/com/ferg/awfulapp/preferences/Keys.java b/Awful.apk/src/main/java/com/ferg/awfulapp/preferences/Keys.java index 91d1c898a..f55bba892 100644 --- a/Awful.apk/src/main/java/com/ferg/awfulapp/preferences/Keys.java +++ b/Awful.apk/src/main/java/com/ferg/awfulapp/preferences/Keys.java @@ -97,6 +97,8 @@ public abstract class Keys { HIGHLIGHT_OP, INLINE_YOUTUBE, INLINE_TWEETS, + INLINE_INSTAGRAM, + INLINE_TWITCH, INLINE_VINES, INLINE_WEBM, AUTOSTART_WEBM, @@ -170,6 +172,8 @@ public abstract class Keys { public static final int HIGHLIGHT_OP = R.string.pref_key_highlight_op; public static final int INLINE_YOUTUBE = R.string.pref_key_inline_youtube; public static final int INLINE_TWEETS = R.string.pref_key_inline_tweets; + public static final int INLINE_INSTAGRAM = R.string.pref_key_inline_instagram; + public static final int INLINE_TWITCH = R.string.pref_key_inline_twitch; public static final int INLINE_VINES = R.string.pref_key_inline_vines; public static final int INLINE_WEBM = R.string.pref_key_inline_webm; public static final int AUTOSTART_WEBM = R.string.pref_key_autostart_webm; diff --git a/Awful.apk/src/main/java/com/ferg/awfulapp/preferences/SettingsActivity.java b/Awful.apk/src/main/java/com/ferg/awfulapp/preferences/SettingsActivity.java index aa740b1dd..a4b7e587c 100644 --- a/Awful.apk/src/main/java/com/ferg/awfulapp/preferences/SettingsActivity.java +++ b/Awful.apk/src/main/java/com/ferg/awfulapp/preferences/SettingsActivity.java @@ -4,9 +4,10 @@ import android.app.Activity; import android.app.AlertDialog; import android.app.Dialog; +import android.app.Fragment; import android.app.FragmentManager; +import android.app.FragmentTransaction; import android.content.Context; -import android.content.DialogInterface; import android.content.Intent; import android.content.pm.PackageManager; import android.content.res.Resources; @@ -15,10 +16,13 @@ import android.os.Bundle; import android.preference.PreferenceManager; import android.provider.MediaStore; +import android.support.annotation.NonNull; import android.support.v4.app.ActivityCompat; import android.support.v4.content.ContextCompat; +import android.support.v7.app.ActionBar; import android.support.v7.widget.Toolbar; import android.util.Log; +import android.view.MenuItem; import android.view.View; import android.widget.Toast; @@ -35,32 +39,31 @@ /** * Created by baka kaba on 04/05/2015. - * + *

* Activity to host a new fragment-based settings system! * Holds a {@link RootSettings} which forms the root menu, and handles and * displays additional {@link SettingsFragment}s in place of PreferenceScreens (which like * to spawn new activities all over the screen). Please see the {@link SettingsFragment} * documentation for information on extending and adding to the Preference hierarchy. - * + *

* In portrait mode the root menu is displayed, and submenus open on top of this, as usual. The * back button walks back through the hierarchy, until the root menu is shown, at which point * the back button will exit the Settings activity. - * + *

* In dual-pane landscape mode, the fragment hierarchy is displayed on the right, and a copy of * the root menu is on the left. Since the root is always visible, the copy in the fragment * hierarchy is hidden, and the back stack will only walk back until the top level of a submenu is * visible. - * + *

* Switching between orientations maintains this state, while ensuring you get the expected behaviour * (e.g. pressing back in dual-pane mode with a top-level submenu displayed will exit, but rotating * to portrait first will display the submenu, and pressing back will move to the root menu) - * */ public class SettingsActivity extends AwfulActivity implements AwfulPreferences.AwfulPreferenceUpdate, SettingsFragment.OnSubmenuSelectedListener { - private static final String ROOT_FRAGMENT_TAG = "rootfragtag"; - private static final String SUBMENU_FRAGMENT_TAG = "subfragtag"; + private static final String ROOT_FRAGMENT_TAG = "rootfragtag"; + private static final String SUBMENU_FRAGMENT_TAG = "subfragtag"; public static final int DIALOG_ABOUT = 1; public static final int SETTINGS_FILE = 2; @@ -86,7 +89,9 @@ public class SettingsActivity extends AwfulActivity implements AwfulPreferences. }; private Intent importData; - /** Initialise all preference defaults from the XML hierarchy */ + /** + * Initialise all preference defaults from the XML hierarchy + */ public static void setDefaultsFromXml(Context context) { for (int id : PREFERENCE_XML_FILES) { PreferenceManager.setDefaultValues(context, id, true); @@ -96,7 +101,7 @@ public static void setDefaultsFromXml(Context context) { @Override protected void onCreate(Bundle savedInstanceState) { - prefs = AwfulPreferences.getInstance(this,this); + prefs = AwfulPreferences.getInstance(this, this); currentThemeName = prefs.theme; setCurrentTheme(); // theme needs to be set BEFORE the super call, or it'll be inconsistent @@ -107,56 +112,71 @@ protected void onCreate(Bundle savedInstanceState) { isDualPane = true; } - Toolbar mToolbar = (Toolbar) findViewById(R.id.awful_toolbar); - setSupportActionBar(mToolbar); - setActionbarTitle(getString(R.string.settings_activity_title), null); - FragmentManager fm = getFragmentManager(); // if there's no previous fragment history being restored, initialise! + // we need to start with the root fragment, so it's always under the backstack if (savedInstanceState == null) { fm.beginTransaction() .replace(R.id.main_fragment_container, new RootSettings(), ROOT_FRAGMENT_TAG) .commit(); - } - // don't display the root fragment in dual pane mode (there's one in the layout) - if (isDualPane) { fm.executePendingTransactions(); - SettingsFragment fragment = (SettingsFragment) fm.findFragmentByTag(ROOT_FRAGMENT_TAG); - if (fragment != null) { + } + + // hide the root fragment in dual-pane mode (there's a copy visible in the layout), + // but make sure it's shown in single-pane (we might have switched from dual-pane) + SettingsFragment fragment = (SettingsFragment) fm.findFragmentByTag(ROOT_FRAGMENT_TAG); + if (fragment != null) { + if (isDualPane) { fm.beginTransaction().hide(fragment).commit(); + } else { + fm.beginTransaction().show(fragment).commit(); } } + + Toolbar toolbar = (Toolbar) findViewById(R.id.awful_toolbar); + setSupportActionBar(toolbar); + getSupportActionBar().setDisplayHomeAsUpEnabled(true); + updateTitleBar(); } + /* * Overridden because the activity descends from the support library, * and looks at the SupportFragmentManager's backstack. We're using * PreferenceFragments which need to use the standard FragmentManager */ @Override - public void onBackPressed() - { + public void onBackPressed() { FragmentManager fm = getFragmentManager(); - int backStackEntryCount = fm.getBackStackEntryCount(); - // don't pop to the root fragment at the base of the fragment hierarchy in dual-pane mode - if (isDualPane && backStackEntryCount > 1) { - fm.popBackStack(); - } else if (!isDualPane && backStackEntryCount > 0) { - fm.popBackStack(); + int backStackCount = fm.getBackStackEntryCount(); + // don't pop off the first entry in dual-pane mode, it will leave the second pane blank - just exit + if (backStackCount == 0 || isDualPane && backStackCount == 1) { + finish(); } else { - super.onBackPressed(); + fm.popBackStackImmediate(); + updateTitleBar(); } } @Override - public void onSubmenuSelected(SettingsFragment container, String submenuFragment) { + public boolean onOptionsItemSelected(MenuItem item) { + if (item.getItemId() == android.R.id.home) { + onBackPressed(); + return true; + } + return super.onOptionsItemSelected(item); + } + + + @Override + public void onSubmenuSelected(@NonNull SettingsFragment sourceFragment, @NonNull String submenuFragmentName) { try { - SettingsFragment fragment = (SettingsFragment)(Class.forName(submenuFragment).newInstance()); - boolean fromRootMenu = container != null && container instanceof RootSettings; + SettingsFragment fragment = (SettingsFragment) (Class.forName(submenuFragmentName).newInstance()); + boolean fromRootMenu = sourceFragment instanceof RootSettings; displayFragment(fragment, fromRootMenu); } catch (IllegalAccessException | ClassNotFoundException | InstantiationException e) { - Log.e(TAG, "Unable to create fragment (" + submenuFragment + ")\n", e); + Log.e(TAG, "Unable to create fragment (" + submenuFragmentName + ")\n", e); } } @@ -184,30 +204,62 @@ private void displayFragment(SettingsFragment fragment, boolean addedFromRoot) { // if we're opening a submenu and there's already one open, wipe it from the back stack FragmentManager fm = getFragmentManager(); - if (addedFromRoot && fm.findFragmentByTag(SUBMENU_FRAGMENT_TAG) != null) { - // when a root submenu is clicked, clear the side pane state first - int fragsAddedToStack = fm.getBackStackEntryCount(); - for (int i = 0; i < fragsAddedToStack; i++) { - fm.popBackStack(); - } + if (addedFromRoot) { + // when a root submenu is clicked, we need a new submenu backstack + clearBackStack(fm); } - fm.beginTransaction().replace(R.id.main_fragment_container, fragment, SUBMENU_FRAGMENT_TAG) + fm.beginTransaction() + .replace(R.id.main_fragment_container, fragment, SUBMENU_FRAGMENT_TAG) .addToBackStack(null) + .setTransition(FragmentTransaction.TRANSIT_FRAGMENT_OPEN) .commit(); + updateTitleBar(); + } + + + private void clearBackStack(FragmentManager fm) { + int fragsAddedToStack = fm.getBackStackEntryCount(); + for (int i = 0; i < fragsAddedToStack; i++) { + fm.popBackStackImmediate(); + } + } + + + /** + * Update the action bar's title according to what's being displayed. + *

+ * Call this whenever the layout or fragment stack changes. + */ + private void updateTitleBar() { + ActionBar actionBar = getSupportActionBar(); + if (actionBar == null) { + return; + } + FragmentManager fm = getFragmentManager(); + // make sure fragment transactions are finished before we poke around in there + fm.executePendingTransactions(); + // if there's a submenu fragment present, get the title from that + // need to check #isAdded because popping the last submenu fragment off the backstack doesn't immediately remove it from the manager, + // i.e. the find call won't return null (but it will later - this was fun to troubleshoot) + Fragment fragment = fm.findFragmentByTag(SUBMENU_FRAGMENT_TAG); + if (fragment == null || !fragment.isAdded()) { + fragment = fm.findFragmentByTag(ROOT_FRAGMENT_TAG); + } + actionBar.setTitle(((SettingsFragment) fragment).getTitle()); } @Override public void onPreferenceChange(AwfulPreferences preferences, String key) { // update the summaries on any loaded fragments - for (String tag : new String[] {ROOT_FRAGMENT_TAG, SUBMENU_FRAGMENT_TAG}) { + for (String tag : new String[]{ROOT_FRAGMENT_TAG, SUBMENU_FRAGMENT_TAG}) { SettingsFragment fragment = (SettingsFragment) getFragmentManager().findFragmentByTag(tag); if (fragment != null) { fragment.setSummaries(); } } - if(!mPrefs.theme.equals(this.currentThemeName)) { + if (!mPrefs.theme.equals(this.currentThemeName)) { this.currentThemeName = mPrefs.theme; setCurrentTheme(); recreate(); @@ -225,21 +277,22 @@ public void onPreferenceChange(AwfulPreferences preferences, String key) { public void onActivityResult(int requestCode, int resultCode, Intent data) { if (resultCode == Activity.RESULT_OK) { if (requestCode == SETTINGS_FILE) { - if(AwfulUtils.isMarshmallow()){ + if (AwfulUtils.isMarshmallow()) { int permissionCheck = ContextCompat.checkSelfPermission(this, Manifest.permission.READ_EXTERNAL_STORAGE); if (permissionCheck != PackageManager.PERMISSION_GRANTED) { this.importData = data; ActivityCompat.requestPermissions(this, new String[]{Manifest.permission.READ_EXTERNAL_STORAGE}, Constants.AWFUL_PERMISSION_READ_EXTERNAL_STORAGE); - }else{ + } else { importFile(data); } - }else { + } else { importFile(data); } } } } - protected void importFile(Intent data){ + + protected void importFile(Intent data) { Toast.makeText(this, "importing settings", Toast.LENGTH_SHORT).show(); Uri selectedSetting = data.getData(); String path = getFilePath(selectedSetting); @@ -254,12 +307,12 @@ protected void importFile(Intent data){ public String getFilePath(Uri uri) { Cursor cursor = null; try { - String[] projection = { MediaStore.Images.Media.DATA }; + String[] projection = {MediaStore.Images.Media.DATA}; cursor = this.getContentResolver().query(uri, projection, null, null, null); int column_index = cursor.getColumnIndexOrThrow(MediaStore.Images.Media.DATA); cursor.moveToFirst(); return cursor.getString(column_index); - } catch(NullPointerException e) { + } catch (NullPointerException e) { Toast.makeText(this, "Your file explorer sent incompatible data, please try a different way", Toast.LENGTH_LONG).show(); e.printStackTrace(); return null; @@ -273,7 +326,7 @@ public String getFilePath(Uri uri) { @Override protected Dialog onCreateDialog(int dialogId) { - switch(dialogId) { + switch (dialogId) { case DIALOG_ABOUT: CharSequence app_version = getText(R.string.app_name); try { @@ -293,11 +346,8 @@ protected Dialog onCreateDialog(int dialogId) { return new AlertDialog.Builder(this) .setTitle(app_version) .setMessage(aboutText) - .setNeutralButton(android.R.string.ok, new DialogInterface.OnClickListener() { - @Override - public void onClick(DialogInterface dialog, int which) { - - }}) + .setNeutralButton(android.R.string.ok, (dialog, which) -> { + }) .create(); default: return super.onCreateDialog(dialogId); diff --git a/Awful.apk/src/main/java/com/ferg/awfulapp/preferences/fragments/AccountSettings.java b/Awful.apk/src/main/java/com/ferg/awfulapp/preferences/fragments/AccountSettings.java index c7a98a14f..11718d212 100644 --- a/Awful.apk/src/main/java/com/ferg/awfulapp/preferences/fragments/AccountSettings.java +++ b/Awful.apk/src/main/java/com/ferg/awfulapp/preferences/fragments/AccountSettings.java @@ -3,6 +3,7 @@ import android.app.Dialog; import android.app.ProgressDialog; import android.preference.Preference; +import android.support.annotation.NonNull; import android.text.TextUtils; import android.widget.Toast; @@ -25,6 +26,13 @@ public class AccountSettings extends SettingsFragment { }); } + + @NonNull + @Override + public String getTitle() { + return getString(R.string.prefs_account); + } + @Override protected void onSetSummaries() { findPrefById(R.string.pref_key_username).setSummary(mPrefs.username); diff --git a/Awful.apk/src/main/java/com/ferg/awfulapp/preferences/fragments/ForumIndexSettings.java b/Awful.apk/src/main/java/com/ferg/awfulapp/preferences/fragments/ForumIndexSettings.java index effea8057..bca726422 100644 --- a/Awful.apk/src/main/java/com/ferg/awfulapp/preferences/fragments/ForumIndexSettings.java +++ b/Awful.apk/src/main/java/com/ferg/awfulapp/preferences/fragments/ForumIndexSettings.java @@ -1,6 +1,7 @@ package com.ferg.awfulapp.preferences.fragments; import android.preference.Preference; +import android.support.annotation.NonNull; import com.ferg.awfulapp.R; import com.ferg.awfulapp.constants.Constants; @@ -37,6 +38,13 @@ public boolean onPreferenceClick(Preference preference) { private volatile boolean updateRunning = false; + @NonNull + @Override + public String getTitle() { + return getString(R.string.forum_index_settings); + } + + @Override protected void initialiseSettings() { super.initialiseSettings(); diff --git a/Awful.apk/src/main/java/com/ferg/awfulapp/preferences/fragments/ImageSettings.java b/Awful.apk/src/main/java/com/ferg/awfulapp/preferences/fragments/ImageSettings.java index d53f5df6a..f2e9c9d19 100644 --- a/Awful.apk/src/main/java/com/ferg/awfulapp/preferences/fragments/ImageSettings.java +++ b/Awful.apk/src/main/java/com/ferg/awfulapp/preferences/fragments/ImageSettings.java @@ -1,5 +1,7 @@ package com.ferg.awfulapp.preferences.fragments; +import android.support.annotation.NonNull; + import com.ferg.awfulapp.R; /** @@ -14,4 +16,10 @@ public class ImageSettings extends SettingsFragment { }; } + + @NonNull + @Override + public String getTitle() { + return getString(R.string.image_settings); + } } diff --git a/Awful.apk/src/main/java/com/ferg/awfulapp/preferences/fragments/MiscSettings.java b/Awful.apk/src/main/java/com/ferg/awfulapp/preferences/fragments/MiscSettings.java index 20ea5da0e..d29b12632 100644 --- a/Awful.apk/src/main/java/com/ferg/awfulapp/preferences/fragments/MiscSettings.java +++ b/Awful.apk/src/main/java/com/ferg/awfulapp/preferences/fragments/MiscSettings.java @@ -4,7 +4,7 @@ import android.os.Build; import android.preference.ListPreference; import android.preference.Preference; -import android.preference.SwitchPreference; +import android.support.annotation.NonNull; import android.view.View; import android.widget.Button; import android.widget.SeekBar; @@ -35,6 +35,13 @@ public class MiscSettings extends SettingsFragment { } + @NonNull + @Override + public String getTitle() { + return getString(R.string.prefs_misc); + } + + @Override protected void initialiseSettings() { super.initialiseSettings(); diff --git a/Awful.apk/src/main/java/com/ferg/awfulapp/preferences/fragments/PostEmbeddingSettings.java b/Awful.apk/src/main/java/com/ferg/awfulapp/preferences/fragments/PostEmbeddingSettings.java index 177c95fd5..be29736e7 100644 --- a/Awful.apk/src/main/java/com/ferg/awfulapp/preferences/fragments/PostEmbeddingSettings.java +++ b/Awful.apk/src/main/java/com/ferg/awfulapp/preferences/fragments/PostEmbeddingSettings.java @@ -1,5 +1,7 @@ package com.ferg.awfulapp.preferences.fragments; +import android.support.annotation.NonNull; + import com.ferg.awfulapp.R; /** @@ -14,4 +16,9 @@ public class PostEmbeddingSettings extends SettingsFragment { } + @NonNull + @Override + public String getTitle() { + return getString(R.string.embedding_settings_title); + } } diff --git a/Awful.apk/src/main/java/com/ferg/awfulapp/preferences/fragments/PostHighlightingSettings.java b/Awful.apk/src/main/java/com/ferg/awfulapp/preferences/fragments/PostHighlightingSettings.java index bd0b4a328..e2e28efbb 100644 --- a/Awful.apk/src/main/java/com/ferg/awfulapp/preferences/fragments/PostHighlightingSettings.java +++ b/Awful.apk/src/main/java/com/ferg/awfulapp/preferences/fragments/PostHighlightingSettings.java @@ -1,5 +1,7 @@ package com.ferg.awfulapp.preferences.fragments; +import android.support.annotation.NonNull; + import com.ferg.awfulapp.R; /** @@ -10,4 +12,11 @@ public class PostHighlightingSettings extends SettingsFragment { { SETTINGS_XML_RES_ID = R.xml.post_highlighting_settings; } + + + @NonNull + @Override + public String getTitle() { + return getString(R.string.highlighting_settings_title); + } } diff --git a/Awful.apk/src/main/java/com/ferg/awfulapp/preferences/fragments/PostSettings.java b/Awful.apk/src/main/java/com/ferg/awfulapp/preferences/fragments/PostSettings.java index 6f808ed41..14964218a 100644 --- a/Awful.apk/src/main/java/com/ferg/awfulapp/preferences/fragments/PostSettings.java +++ b/Awful.apk/src/main/java/com/ferg/awfulapp/preferences/fragments/PostSettings.java @@ -4,6 +4,7 @@ import android.content.DialogInterface; import android.graphics.Typeface; import android.preference.Preference; +import android.support.annotation.NonNull; import android.util.Log; import android.util.TypedValue; import android.widget.Button; @@ -40,6 +41,13 @@ public class PostSettings extends SettingsFragment { }); } + + @NonNull + @Override + public String getTitle() { + return getString(R.string.prefs_post_display); + } + @SuppressWarnings("ConstantConditions") @Override protected void onSetSummaries() { diff --git a/Awful.apk/src/main/java/com/ferg/awfulapp/preferences/fragments/RootSettings.java b/Awful.apk/src/main/java/com/ferg/awfulapp/preferences/fragments/RootSettings.java index 63a1a5783..4ec956f37 100644 --- a/Awful.apk/src/main/java/com/ferg/awfulapp/preferences/fragments/RootSettings.java +++ b/Awful.apk/src/main/java/com/ferg/awfulapp/preferences/fragments/RootSettings.java @@ -55,6 +55,11 @@ public class RootSettings extends SettingsFragment { }); } + @NonNull + @Override + public String getTitle() { + return getString(R.string.settings_activity_title); + } /** Listener for the 'About...' option */ private class AboutListener implements Preference.OnPreferenceClickListener { diff --git a/Awful.apk/src/main/java/com/ferg/awfulapp/preferences/fragments/SettingsFragment.java b/Awful.apk/src/main/java/com/ferg/awfulapp/preferences/fragments/SettingsFragment.java index c82a13ded..813887f56 100644 --- a/Awful.apk/src/main/java/com/ferg/awfulapp/preferences/fragments/SettingsFragment.java +++ b/Awful.apk/src/main/java/com/ferg/awfulapp/preferences/fragments/SettingsFragment.java @@ -2,45 +2,37 @@ import android.app.Activity; import android.content.res.Resources; -import android.content.res.TypedArray; -import android.graphics.ColorFilter; import android.graphics.PorterDuff; -import android.graphics.PorterDuffColorFilter; import android.graphics.drawable.Drawable; import android.os.Bundle; import android.preference.ListPreference; import android.preference.Preference; import android.preference.PreferenceFragment; +import android.support.annotation.NonNull; import android.support.annotation.Nullable; import android.support.annotation.StringRes; -import android.support.v4.content.ContextCompat; -import android.support.v4.graphics.drawable.DrawableCompat; import android.support.v4.util.ArrayMap; import android.util.Log; import android.util.TypedValue; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; import android.widget.ListView; import com.ferg.awfulapp.R; import com.ferg.awfulapp.preferences.AwfulPreferences; import com.ferg.awfulapp.preferences.SettingsActivity; -import com.ferg.awfulapp.provider.AwfulTheme; import java.util.Map; /** * Created by baka kaba on 04/05/2015. - * + *

*

Base fragment that adds preferences from a given XML resource, * sets defaults and enables options based on the user's device, * and sets preference summaries.

- * + *

*

For some fragments the XML resource ID is all that's required. * Others may need to specify preferences etc., or override the * initialisation methods to perform more advanced shenanigans.

- * + *

*

When adding a new fragment, please add its XML resId to * {@link SettingsActivity#PREFERENCE_XML_FILES} so it can be * automatically checked for defaults!

@@ -76,17 +68,17 @@ public abstract class SettingsFragment extends PreferenceFragment { /** *

This must be set to the resource ID of a layout file containing the fragment's preferences

- * + *

*

Layout files should describe a single level in the preference hierarchy - * don't use the standard {@link android.preference.PreferenceScreen} behaviour to define * additional levels, as they will launch a separate activity.

- * + *

*

Instead, create a separate fragment to hold that content, and define a preference in * this layout which will open that fragment when clicked. Set this preference's * android:fragment value to this target fragment, and add the preference's key * to the SUBMENU_OPENING_KEYS array to enable its click behaviour. * See the {@link RootSettings} class for an example

- * + *

*

(This isn't ideal, it would be better if the click listener was added automatically wherever * a fragment value is set on a preference in the XML, so if anyone can handle that cleanly be my guest)

*/ @@ -119,8 +111,6 @@ public abstract class SettingsFragment extends PreferenceFragment { */ protected Map prefClickListeners = new ArrayMap<>(); - // TODO: 26/04/2017 Fragment titles for submenus as per https://material.io/guidelines/patterns/settings.html#settings-labels-secondary-text - @Override public void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); @@ -143,7 +133,7 @@ public void onActivityCreated(@Nullable Bundle savedInstanceState) { super.onActivityCreated(savedInstanceState); // for some reason, if you theme android:listDivider it won't show up in the preference list // so doing this directly seems to be the only way to theme it? Can't just get() it either - ListView listview = (ListView) getActivity().findViewById(android.R.id.list); + ListView listview = (ListView) getView().findViewById(android.R.id.list); Drawable divider = getResources().getDrawable(R.drawable.list_divider); TypedValue colour = new TypedValue(); getActivity().getTheme().resolveAttribute(android.R.attr.listDivider, colour, true); @@ -155,7 +145,17 @@ public void onActivityCreated(@Nullable Bundle savedInstanceState) { * Set required defaults and selectively enable preferences. * Override this to perform any custom initialisation in the fragment */ - protected void initialiseSettings() { } + protected void initialiseSettings() { + } + + + /** + * Get a title for this fragment - this should usually be the same as the label of the preference + * that opened it, e.g. clicking 'Images' should open a fragment whose title is 'Images'. + * See the Material Design specs + */ + @NonNull + public abstract String getTitle(); /** @@ -199,7 +199,8 @@ public final synchronized void setSummaries() { * Override this if you want to perform any special handling * when a summary update call comes in. */ - protected void onSetSummaries() { } + protected void onSetSummaries() { + } /** @@ -254,14 +255,17 @@ public void onAttach(Activity activity) { public interface OnSubmenuSelectedListener { /** * Respond to a click on a preference that opens a submenu - * @param container The fragment containing the clicked preference - * @param submenuFragment The name of the submenu fragment's class + * + * @param sourceFragment The fragment containing the clicked preference + * @param submenuFragmentName The name of the submenu fragment's class */ - void onSubmenuSelected(SettingsFragment container, String submenuFragment); + void onSubmenuSelected(@NonNull SettingsFragment sourceFragment, @NonNull String submenuFragmentName); } - /** Listener for clicks on options that open submenus */ + /** + * Listener for clicks on options that open submenus + */ private class SubmenuListener implements Preference.OnPreferenceClickListener { private final SettingsFragment mThis; diff --git a/Awful.apk/src/main/java/com/ferg/awfulapp/preferences/fragments/ThemeSettings.java b/Awful.apk/src/main/java/com/ferg/awfulapp/preferences/fragments/ThemeSettings.java index caefad1a5..62a860832 100644 --- a/Awful.apk/src/main/java/com/ferg/awfulapp/preferences/fragments/ThemeSettings.java +++ b/Awful.apk/src/main/java/com/ferg/awfulapp/preferences/fragments/ThemeSettings.java @@ -47,6 +47,13 @@ public class ThemeSettings extends SettingsFragment { private static final String TAG = "ThemeSettings"; + + @NonNull + @Override + public String getTitle() { + return getString(R.string.theme_settings); + } + @Override protected void initialiseSettings() { super.initialiseSettings(); @@ -60,7 +67,7 @@ protected void initialiseSettings() { new AlertDialog.Builder(activity) .setMessage(R.string.permission_rationale_external_storage) .setTitle("Permission request") - .setIcon(R.mipmap.ic_launcher) + .setIcon(R.drawable.frog_icon) .setPositiveButton("Got it", (dialogInterface, i) -> {}) .setOnDismissListener(dialogInterface -> requestStoragePermissions()) .show(); diff --git a/Awful.apk/src/main/java/com/ferg/awfulapp/preferences/fragments/ThreadSettings.java b/Awful.apk/src/main/java/com/ferg/awfulapp/preferences/fragments/ThreadSettings.java index cda1e0c94..0ea6c55d5 100644 --- a/Awful.apk/src/main/java/com/ferg/awfulapp/preferences/fragments/ThreadSettings.java +++ b/Awful.apk/src/main/java/com/ferg/awfulapp/preferences/fragments/ThreadSettings.java @@ -1,5 +1,7 @@ package com.ferg.awfulapp.preferences.fragments; +import android.support.annotation.NonNull; + import com.ferg.awfulapp.R; /** @@ -10,4 +12,10 @@ public class ThreadSettings extends SettingsFragment { { SETTINGS_XML_RES_ID = R.xml.threadinfosettings; } + + @NonNull + @Override + public String getTitle() { + return getString(R.string.thread_settings); + } } \ No newline at end of file diff --git a/Awful.apk/src/main/java/com/ferg/awfulapp/reply/BasicTextInserter.java b/Awful.apk/src/main/java/com/ferg/awfulapp/reply/BasicTextInserter.java index e4618d3d6..7ff0cc3e3 100644 --- a/Awful.apk/src/main/java/com/ferg/awfulapp/reply/BasicTextInserter.java +++ b/Awful.apk/src/main/java/com/ferg/awfulapp/reply/BasicTextInserter.java @@ -17,7 +17,7 @@ * Handles inserting basic, unparameterised BBcode tags into EditTexts. */ -public abstract class BasicTextInserter extends Inserter { +abstract class BasicTextInserter extends Inserter { /** * Wrap selected text in a BBcode tag, or show a dialog to insert some. @@ -34,13 +34,13 @@ public abstract class BasicTextInserter extends Inserter { * @param tag The tag type to add * @param activity The current Activity, used to display the dialog UI */ - public static void insert(@NonNull final EditText replyMessage, - @NonNull final BbCodeTag tag, - @NonNull final Activity activity) { + static void smartInsert(@NonNull final EditText replyMessage, + @NonNull final BbCodeTag tag, + @NonNull final Activity activity) { // if there's text selected, just wrap it - don't show a dialog String selectedText = getSelectedText(replyMessage); if (selectedText != null && !selectedText.isEmpty()) { - doInsert(replyMessage, selectedText, tag); + insertWithoutDialog(replyMessage, selectedText, tag); return; } @@ -52,15 +52,9 @@ public static void insert(@NonNull final EditText replyMessage, } setToSelection(textField, replyMessage); - DialogInterface.OnClickListener clickListener = new DialogInterface.OnClickListener() { - @Override - public void onClick(DialogInterface dialog, int which) { - doInsert(replyMessage, textField.getText().toString(), tag); - } - }; - - getDialogBuilder(activity, layout, clickListener) - .setTitle(tag.dialogTitle).show(); + DialogInterface.OnClickListener clickListener = (dialog, which) -> + insertWithoutDialog(replyMessage, textField.getText().toString(), tag); + getDialogBuilder(activity, layout, clickListener).setTitle(tag.dialogTitle).show(); } /** @@ -70,7 +64,7 @@ public void onClick(DialogInterface dialog, int which) { * @param text The text being wrapped * @param tag The tag to wrap with */ - private static void doInsert(@NonNull EditText replyMessage, @NonNull String text, @NonNull BbCodeTag tag) { + static void insertWithoutDialog(@NonNull EditText replyMessage, @NonNull String text, @NonNull BbCodeTag tag) { String bbCode = String.format(tag.tagFormatString, text); insertIntoReply(replyMessage, bbCode); } @@ -79,7 +73,7 @@ private static void doInsert(@NonNull EditText replyMessage, @NonNull String tex /** * Represents simple (parameterless) BBcode tags. */ - public enum BbCodeTag { + enum BbCodeTag { BOLD("Insert bold text", "[b]%s[/b]"), ITALICS("Insert italic text", "[i]%s[/i]"), UNDERLINE("Insert underlined text", "[u]%s[/u]"), diff --git a/Awful.apk/src/main/java/com/ferg/awfulapp/reply/CodeInserter.java b/Awful.apk/src/main/java/com/ferg/awfulapp/reply/CodeInserter.java index 6de2845d5..7dd4a8c9e 100644 --- a/Awful.apk/src/main/java/com/ferg/awfulapp/reply/CodeInserter.java +++ b/Awful.apk/src/main/java/com/ferg/awfulapp/reply/CodeInserter.java @@ -1,7 +1,6 @@ package com.ferg.awfulapp.reply; import android.app.Activity; -import android.app.Dialog; import android.content.DialogInterface; import android.support.annotation.NonNull; import android.support.annotation.Nullable; @@ -17,7 +16,7 @@ * Handles inserting BBcode code blocks into EditTexts. */ -public abstract class CodeInserter extends Inserter { +abstract class CodeInserter extends Inserter { /** * Show a dialog to add a code block in a reply's EditText. @@ -30,19 +29,16 @@ public abstract class CodeInserter extends Inserter { * @param replyMessage The wrapped text will be added here * @param activity The current Activity, used to display the dialog UI */ - public static void insert(@NonNull final EditText replyMessage, @NonNull final Activity activity) { + static void smartInsert(@NonNull final EditText replyMessage, @NonNull final Activity activity) { View layout = getDialogLayout(R.layout.insert_code_dialog, activity); final EditText textField = (EditText) layout.findViewById(R.id.text_field); final Spinner languageSpinner = (Spinner) layout.findViewById(R.id.language_spinner); setToSelection(textField, replyMessage); - DialogInterface.OnClickListener clickListener = new Dialog.OnClickListener() { - @Override - public void onClick(DialogInterface dialog, int which) { - // the first option in the dropdown should always be the 'no highlighting' option - String language = languageSpinner.getSelectedItemPosition() == 0 ? null : (String) languageSpinner.getSelectedItem(); - doInsert(replyMessage, textField.getText().toString(), language); - } + DialogInterface.OnClickListener clickListener = (dialog, which) -> { + // the first option in the dropdown should always be the 'no highlighting' option + String language = languageSpinner.getSelectedItemPosition() == 0 ? null : (String) languageSpinner.getSelectedItem(); + insertWithoutDialog(replyMessage, textField.getText().toString(), language); }; getDialogBuilder(activity, layout, clickListener).setTitle("Insert code block").show(); @@ -58,7 +54,7 @@ public void onClick(DialogInterface dialog, int which) { * @param codeText the code block's text * @param language an optional language name to apply as a code tag parameter */ - private static void doInsert(@NonNull EditText replyMessage, @NonNull String codeText, @Nullable String language) { + static void insertWithoutDialog(@NonNull EditText replyMessage, @NonNull String codeText, @Nullable String language) { // it's a block element so it's better to have line breaks around it String languageParam = language == null ? "" : "=" + language; String bbCode = String.format("%n[code%s]%n%s%n[/code]%n", languageParam, codeText); diff --git a/Awful.apk/src/main/java/com/ferg/awfulapp/reply/ImageInserter.java b/Awful.apk/src/main/java/com/ferg/awfulapp/reply/ImageInserter.java index 98fa6d686..f9005b825 100644 --- a/Awful.apk/src/main/java/com/ferg/awfulapp/reply/ImageInserter.java +++ b/Awful.apk/src/main/java/com/ferg/awfulapp/reply/ImageInserter.java @@ -15,7 +15,7 @@ * Handles inserting BBcode image tags into EditTexts */ -public abstract class ImageInserter extends Inserter { +abstract class ImageInserter extends Inserter { /** * Display a dialog to insert an image, with options. @@ -26,7 +26,7 @@ public abstract class ImageInserter extends Inserter { * @param replyMessage The wrapped text will be added here * @param activity The current Activity, used to display the dialog UI */ - public static void insert(@NonNull final EditText replyMessage, @NonNull final Activity activity) { + static void smartInsert(@NonNull final EditText replyMessage, @NonNull final Activity activity) { View layout = getDialogLayout(R.layout.insert_image_dialog, activity); final EditText urlField = (EditText) layout.findViewById(R.id.url_field); final CheckBox thumbnailCheckbox = (CheckBox) layout.findViewById(R.id.use_thumbnail); @@ -40,26 +40,20 @@ public static void insert(@NonNull final EditText replyMessage, @NonNull final A setText(urlField, clipboardText); } - DialogInterface.OnClickListener clickListener = new DialogInterface.OnClickListener() { - - @Override - public void onClick(DialogInterface dialog, int which) { - doInsert(replyMessage, urlField.getText().toString(), thumbnailCheckbox.isChecked()); - } - }; - + DialogInterface.OnClickListener clickListener = (dialog, which) -> + insertWithoutDialog(replyMessage, urlField.getText().toString(), thumbnailCheckbox.isChecked()); getDialogBuilder(activity, layout, clickListener).setTitle("Insert image").show(); } /** - * Perform the insert, either as an image or a thumbnail image. + * Format a URL with BBcode image tags and insert into a reply. * * @param replyMessage The reply message being edited * @param url the image URL * @param useThumbnail true to use thumbnail tags */ - private static void doInsert(@NonNull EditText replyMessage, @NonNull String url, boolean useThumbnail) { + static void insertWithoutDialog(@NonNull EditText replyMessage, @NonNull String url, boolean useThumbnail) { //noinspection SpellCheckingInspection String template = useThumbnail ? "[timg]%s[/timg]" : "[img]%s[/img]"; String bbCode = String.format(template, url); diff --git a/Awful.apk/src/main/java/com/ferg/awfulapp/reply/ImgurInserter.java b/Awful.apk/src/main/java/com/ferg/awfulapp/reply/ImgurInserter.java new file mode 100644 index 000000000..0be5a6efc --- /dev/null +++ b/Awful.apk/src/main/java/com/ferg/awfulapp/reply/ImgurInserter.java @@ -0,0 +1,575 @@ +package com.ferg.awfulapp.reply; + +import android.app.Activity; +import android.app.Dialog; +import android.content.ContentResolver; +import android.content.DialogInterface; +import android.content.Intent; +import android.database.Cursor; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.graphics.drawable.BitmapDrawable; +import android.net.Uri; +import android.os.Bundle; +import android.provider.OpenableColumns; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import android.support.design.widget.TextInputLayout; +import android.support.v4.app.DialogFragment; +import android.support.v4.app.Fragment; +import android.support.v4.app.FragmentActivity; +import android.support.v7.app.AlertDialog; +import android.text.format.DateFormat; +import android.text.format.Formatter; +import android.util.Log; +import android.util.Pair; +import android.util.Patterns; +import android.view.View; +import android.view.ViewGroup; +import android.widget.Button; +import android.widget.CheckBox; +import android.widget.EditText; +import android.widget.ImageView; +import android.widget.ProgressBar; +import android.widget.Spinner; +import android.widget.TextView; + +import com.android.volley.Request; +import com.android.volley.VolleyError; +import com.ferg.awfulapp.R; +import com.ferg.awfulapp.network.NetworkUtils; +import com.ferg.awfulapp.task.ImgurUploadRequest; + +import org.apache.commons.lang3.StringUtils; +import org.json.JSONException; +import org.json.JSONObject; + +import java.io.FileNotFoundException; +import java.io.InputStream; +import java.io.UnsupportedEncodingException; + +import butterknife.BindView; +import butterknife.OnClick; +import butterknife.OnItemSelected; +import butterknife.OnTextChanged; + +import static android.view.View.GONE; +import static android.view.View.VISIBLE; +import static butterknife.ButterKnife.bind; + +/** + * Created by baka kaba on 31/05/2017. + *

+ * A dialog that allows the user to host an image on Imgur, and inserts the resulting BBcode. + *

+ * The user can choose an upload type (an image file, or the URL of an image already elsewhere on + * the internet), add their source, and pick any relevant options. If the upload is successful, the + * image is inserted as BBcode. Use {@link DialogFragment#setTargetFragment(Fragment, int)} to pass + * the {@link MessageComposer} where the code will be inserted. + */ +public class ImgurInserter extends DialogFragment { + + public static final String TAG = "ImgurInserter"; + private static final int IMGUR_IMAGE_PICKER = 3452; + + private java.text.DateFormat dateFormat; + private java.text.DateFormat timeFormat; + + @BindView(R.id.upload_type) + Spinner uploadTypeSelector; + + @BindView(R.id.upload_image_section) + ViewGroup uploadImageSection; + @BindView(R.id.image_preview) + ImageView imagePreview; + @BindView(R.id.image_name) + TextView imageNameLabel; + @BindView(R.id.image_details) + TextView imageDetailsLabel; + + @BindView(R.id.upload_url_text_input_layout) + TextInputLayout uploadUrlTextWrapper; + @BindView(R.id.upload_url_edittext) + EditText uploadUrlEditText; + + @BindView(R.id.use_thumbnail) + CheckBox thumbnailCheckbox; + @BindView(R.id.add_gifs_as_video) + CheckBox gifsAsVideoCheckbox; + + @BindView(R.id.upload_status) + TextView uploadStatus; + @BindView(R.id.upload_progress_bar) + ProgressBar uploadProgressBar; + @BindView(R.id.remaining_uploads) + TextView remainingUploads; + @BindView(R.id.credits_reset_time) + TextView creditsResetTime; + + private Button uploadButton; + + Uri imageFile = null; + Bitmap previewBitmap = null; + Request uploadTask = null; + State state; + boolean uploadSourceIsUrl; + + + @NonNull + @Override + public Dialog onCreateDialog(Bundle savedInstanceState) { + FragmentActivity activity = getActivity(); + dateFormat = DateFormat.getDateFormat(activity); + timeFormat = DateFormat.getTimeFormat(activity); + + View layout = activity.getLayoutInflater().inflate(R.layout.insert_imgur_dialog, null); + bind(this, layout); + AlertDialog dialog = new AlertDialog.Builder(activity) + .setTitle(R.string.imgur_uploader_dialog_title) + .setView(layout) + .setPositiveButton(R.string.imgur_uploader_ok_button, null) + .setNegativeButton(R.string.cancel, (dialogInterface, i) -> dismiss()) + .show(); + // get the dialog's 'upload' positive button so we can enable and disable it + // setting the click listener directly prevents the dialog from dismissing, so the upload can run + uploadButton = dialog.getButton(AlertDialog.BUTTON_POSITIVE); + uploadButton.setOnClickListener(view -> startUpload()); + // TODO: 05/06/2017 is that method guaranteed to be fired when the system creates the spinner and sets the first item? + updateUploadType(); + updateRemainingUploads(); + return dialog; + } + + + @Override + public void onDismiss(DialogInterface dialog) { + Log.d(TAG, "onDismiss: stopping upload task"); + cancelUploadTask(); + super.onDismiss(dialog); + } + + + /** + * Cancel any currently running upload. + */ + private void cancelUploadTask() { + if (uploadTask != null) { + uploadTask.cancel(); + } + uploadTask = null; + } + + + /** + * Check the currently selected upload type, and update state as necessary. + */ + @OnItemSelected(R.id.upload_type) + void updateUploadType() { + // this assumes the first entry in the spinner is URL, and the second is IMAGE + int position = uploadTypeSelector.getSelectedItemPosition(); + uploadSourceIsUrl = (position == 0); + setState(State.CHOOSING); + } + + + /** + * Check the number of uploads the user can perform, updating state as necessary. + */ + void updateRemainingUploads() { + Pair uploadLimit = ImgurUploadRequest.getCurrentUploadLimit(); + Integer remaining = uploadLimit.first; + Long resetTime = uploadLimit.second; + creditsResetTime.setText(resetTime == null ? "" : getString(R.string.imgur_uploader_remaining_uploads_reset_time, timeFormat.format(resetTime), dateFormat.format(resetTime))); + + if (remaining == null) { + remainingUploads.setText(R.string.imgur_uploader_remaining_uploads_unknown); + creditsResetTime.setText(""); + } else { + remainingUploads.setText(getResources().getQuantityString(R.plurals.imgur_uploader_remaining_uploads, remaining, remaining)); + if (remaining < 1) { + setState(State.NO_UPLOAD_CREDITS); + } + } + } + + + /////////////////////////////////////////////////////////////////////////// + // Choosing an image file + /////////////////////////////////////////////////////////////////////////// + + /** + * Display an image chooser to pick a file to upload. + */ + @OnClick(R.id.upload_image_section) + void launchImagePicker() { + Intent intent = new Intent(Intent.ACTION_GET_CONTENT) + .addCategory(Intent.CATEGORY_OPENABLE) + .setType("image/*"); + Intent chooser = Intent.createChooser(intent, getString(R.string.imgur_uploader_file_chooser_title)); + startActivityForResult(chooser, IMGUR_IMAGE_PICKER); + } + + + @Override + public void onActivityResult(int requestCode, int resultCode, Intent data) { + if (requestCode == IMGUR_IMAGE_PICKER && resultCode == Activity.RESULT_OK) { + if (data != null) { + Uri imageUri = data.getData(); + if (imageUri != null) { + onImageSelected(imageUri); + } + } + } + } + + + /** + * Handle a newly selected image source, displaying a preview and updating state. + */ + private void onImageSelected(@NonNull Uri imageUri) { + // check if this image looks invalid - if so, complain instead of using it + String invalidReason = reasonImageIsInvalid(imageUri); + if (invalidReason != null) { + uploadStatus.setText(invalidReason); + return; + } + + // looks ok, so proceed with it + setState(State.READY_TO_UPLOAD); + imageFile = imageUri; + displayImageDetails(imageUri); + displayImagePreview(imageUri); + } + + + /** + * Try to ascertain if an image is invalid. + *

+ * This attempts to do some checks, e.g. file size, to determine if an image is definitely + * invalid. It's possible that some checks can't be performed (e.g. if data isn't available), + * so a value of false doesn't necessarily mean the image is valid. + */ + @Nullable + private String reasonImageIsInvalid(@NonNull Uri imageUri) { + long maxUploadSize = 10L * 1024 * 1024; // 10MB limit + Long imageSizeBytes = getFileNameAndSize(imageUri).second; + if (imageSizeBytes != null && imageSizeBytes > maxUploadSize) { + String fullFileSize = Formatter.formatFileSize(getContext(), imageSizeBytes); + return getString(R.string.imgur_uploader_error_image_too_large, fullFileSize); + } + // haven't hit any obvious issues, so it's not invalid (as far as we can tell) + return null; + } + + + /** + * Get the name and size of a file, if possible. + * + * @return a [name, size] pair, where attributes are null if no data was available for them + */ + @NonNull + private Pair getFileNameAndSize(@NonNull Uri fileUri) { + // TODO: 17/07/2017 this could be pulled out somewhere and used for this and attachment handling + Cursor cursor = null; + try { + cursor = getActivity().getContentResolver().query(fileUri, null, null, null, null); + if (cursor != null && cursor.moveToFirst()) { + String name = cursor.getString(cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)); + Long size; + try { + size = Long.parseLong(cursor.getString(cursor.getColumnIndex(OpenableColumns.SIZE))); + } catch (NumberFormatException e) { + size = null; + } + return new Pair<>(name, size); + } else { + // no data for this Uri + return new Pair<>(null, null); + } + } finally { + if (cursor != null) { + cursor.close(); + } + } + } + + + /** + * Display file information for an image, if possible. + */ + private void displayImageDetails(@NonNull Uri imageUri) { + Pair nameAndSize = getFileNameAndSize(imageUri); + if (nameAndSize.first == null && nameAndSize.second == null) { + imageNameLabel.setText(""); + imageDetailsLabel.setText(R.string.imgur_uploader_no_file_details); + } else { + String name = (nameAndSize.first == null) ? getString(R.string.imgur_uploader_unknown_value) : nameAndSize.first; + imageNameLabel.setText(getString(R.string.imgur_uploader_file_name, name)); + String size = (nameAndSize.second == null) ? getString(R.string.imgur_uploader_unknown_value) : Formatter.formatShortFileSize(getContext(), nameAndSize.second); + imageDetailsLabel.setText(getString(R.string.imgur_uploader_file_size, size)); + } + } + + + /** + * Display a preview thumbnail for an image. + */ + private void displayImagePreview(@NonNull Uri imageUri) { + if (previewBitmap != null) { + previewBitmap.recycle(); + } + InputStream inputStream; + try { + // TODO: 30/05/2017 non-bad image preview + BitmapFactory.Options options = new BitmapFactory.Options(); + options.inSampleSize = 4; + inputStream = getActivity().getContentResolver().openInputStream(imageUri); + previewBitmap = BitmapFactory.decodeStream(inputStream, null, options); + imagePreview.setImageDrawable(new BitmapDrawable(previewBitmap)); + } catch (FileNotFoundException e) { + e.printStackTrace(); + // TODO: 05/06/2017 'no preview' or something? + } + } + + + /////////////////////////////////////////////////////////////////////////// + // Entering a URL + /////////////////////////////////////////////////////////////////////////// + + + /** + * Handle changes to the 'image source URL' field, updating state where necessary. + */ + @OnTextChanged(R.id.upload_url_edittext) + void onUrlTextChanged() { + // change state when (and only when) the url contents no longer match the current state + // this also avoids a circular call when the url is reset in #setState (url becomes empty + // but state is already set appropriately to CHOOSING) + boolean urlIsEmpty = uploadUrlEditText.length() == 0; + if (urlIsEmpty && state == State.READY_TO_UPLOAD) { + setState(State.CHOOSING); + } else if (!urlIsEmpty && state == State.CHOOSING) { + setState(State.READY_TO_UPLOAD); + } + + // if there's some text, do some validation warning checks + if (urlIsEmpty) { + return; + } + + String url = uploadUrlEditText.getText().toString().toLowerCase(); + boolean looksLikeUrl = Patterns.WEB_URL.matcher(url).matches(); + String warningMessage = null; + if (!StringUtils.startsWithAny(url, "http://", "https://")) { + // TODO: 17/07/2017 uploading without these will fail - should really disable the button, or implicitly add a prefix (but then we have to guess which is valid...) + warningMessage = getString(R.string.imgur_uploader_url_prefix_warning); + } else if (!looksLikeUrl) { + warningMessage = getString(R.string.imgur_uploader_url_validation_warning); + } + uploadUrlTextWrapper.setError(warningMessage); + } + + + /////////////////////////////////////////////////////////////////////////// + // Upload request and normal response handling + /////////////////////////////////////////////////////////////////////////// + + + /** + * Attempt to start an upload for the current image source, cancelling any upload in progress. + */ + void startUpload() { + if (state != State.READY_TO_UPLOAD) { + return; + } + setState(State.UPLOADING); + cancelUploadTask(); + + // do a url if we have one + if (uploadSourceIsUrl) { + uploadTask = new ImgurUploadRequest(uploadUrlEditText.getText().toString(), this::parseUploadResponse, this::handleUploadError); + NetworkUtils.queueRequest(uploadTask); + } else { + ContentResolver contentResolver = getActivity().getContentResolver(); + if (contentResolver != null) { + try { + InputStream inputStream = contentResolver.openInputStream(imageFile); + if (inputStream != null) { + uploadTask = new ImgurUploadRequest(inputStream, this::parseUploadResponse, this::handleUploadError); + } + } catch (FileNotFoundException e) { + e.printStackTrace(); + } + } + if (uploadTask == null) { + onUploadError(getString(R.string.imgur_uploader_error_file_access)); + } else { + NetworkUtils.queueRequest(uploadTask); + } + } + } + + + /** + * Parse and handle the response from the Imgur API. + *

+ * This checks for a successful result, pulls out the hosted image's URL and inserts it into + * the target fragment (which should be a {@link MessageComposer}). If the upload failed, + * the error state is handled. + * + * @see https://apidocs.imgur.com/ + */ + private void parseUploadResponse(JSONObject response) { + try { + boolean success = response.getBoolean("success"); + if (success) { + if (previewBitmap != null) { + previewBitmap.recycle(); + } + JSONObject data = response.getJSONObject("data"); + String videoUrl = StringUtils.defaultIfBlank(data.optString("gifv"), data.optString("mp4")); + String imageUrl = data.getString("link"); + if (gifsAsVideoCheckbox.isChecked() && StringUtils.isNotBlank(videoUrl)) { + ((MessageComposer) getTargetFragment()).onHtml5VideoUploaded(videoUrl); + } else { + ((MessageComposer) getTargetFragment()).onImageUploaded(imageUrl, thumbnailCheckbox.isChecked()); + } + dismiss(); + return; + } + // no success? Guess it's an error then...? + onUploadError(getErrorMessageFromResponseData(response)); + } catch (JSONException e) { + onUploadError(getString(R.string.imgur_uploader_error_site_response_unrecognised)); + Log.w(TAG, "parseUploadResponse: failed to parse Imgur response, unexpected structure?", e); + } + } + + + /////////////////////////////////////////////////////////////////////////// + // Error handling + /////////////////////////////////////////////////////////////////////////// + + + /** + * Display an error message and fall back to the 'ready to upload' state. + */ + private void onUploadError(@NonNull String errorMessage) { + // revert back to pre-upload state + setState(State.READY_TO_UPLOAD); + uploadStatus.setText(errorMessage); + updateRemainingUploads(); + } + + + /** + * Handle network errors and Imgur-specific errors from an upload request. + */ + private void handleUploadError(VolleyError error) { + Log.d(TAG, "Network error: " + error.getMessage(), error.getCause()); + // try and parse out some Imgur-specific error details from the response, and display their error message + JSONObject responseData = null; + if (error.networkResponse != null && error.networkResponse.data != null) { + try { + responseData = new JSONObject(new String(error.networkResponse.data, "UTF-8")); + } catch (UnsupportedEncodingException | JSONException e) { + Log.w(TAG, "handleUploadError: couldn't convert response data to JSON\n", e); + } + } + onUploadError(getErrorMessageFromResponseData(responseData)); + } + + + /** + * Attempt to extract an error message from an Imgur response's JSON. + * + * @param responseData the returned JSON, or null to get a default error message + */ + @NonNull + private String getErrorMessageFromResponseData(@Nullable JSONObject responseData) { + if (responseData != null) { + try { + // thanks for the inconsistent JSON structure for various errors guys - "error" is either a string or a bunch of data with a "message" string + JSONObject errorData = responseData.getJSONObject("data"); + JSONObject errorObject = errorData.optJSONObject("error"); + return (errorObject != null) ? errorObject.getString("message") : errorData.getString("error"); + } catch (JSONException e) { + Log.w(TAG, "getErrorMessageFromResponseData: failed to parse error response correctly\n" + responseData, e); + } + } + // generic message for null/bad data + return getString(R.string.imgur_uploader_error_upload_generic); + } + + + /////////////////////////////////////////////////////////////////////////// + // State transitions + /////////////////////////////////////////////////////////////////////////// + + + /** + * Move to a new state, and update the UI as appropriate. + *

+ * This method defines the different states that the dialog can be in, hiding/showing and + * enabling/disabling UI elements to move between states and limit what the user can do at each + * stage. + */ + private void setState(State newState) { + if (state == State.NO_UPLOAD_CREDITS) { + // make this state permanent - if we hit it, no moving back to CHOOSING etc + return; + } + state = newState; + switch (state) { + // initial state, choosing an upload source + case CHOOSING: + // this intentionally sets the appearing view to visible BEFORE removing the other + // which avoids too much weirdness with the layout change animation + if (uploadSourceIsUrl) { + uploadUrlTextWrapper.setVisibility(VISIBLE); + uploadImageSection.setVisibility(GONE); + } else { + uploadImageSection.setVisibility(VISIBLE); + uploadUrlTextWrapper.setVisibility(GONE); + } + imagePreview.setImageResource(R.drawable.ic_photo_dark); + imageNameLabel.setText(""); + imageDetailsLabel.setText(R.string.imgur_uploader_tap_to_choose_file); + uploadUrlEditText.setText(""); + uploadUrlTextWrapper.setError(null); + + uploadButton.setEnabled(false); + uploadStatus.setText(uploadSourceIsUrl ? getString(R.string.imgur_uploader_status_enter_image_url) : getString(R.string.imgur_uploader_status_choose_source_file)); + uploadProgressBar.setVisibility(GONE); + break; + + // upload source selected (either a URL entered, or a source file chosen) + case READY_TO_UPLOAD: + uploadButton.setEnabled(true); + uploadStatus.setText(R.string.imgur_uploader_status_ready_to_upload); + uploadProgressBar.setVisibility(GONE); + break; + + // upload request in progress + case UPLOADING: + uploadButton.setEnabled(false); + uploadStatus.setText(R.string.imgur_uploader_status_upload_in_progress); + uploadProgressBar.setVisibility(VISIBLE); + break; + + // error state for when we can't upload + case NO_UPLOAD_CREDITS: + // put on the brakes, hide everything and prevent uploads + uploadButton.setEnabled(false); + uploadStatus.setText(R.string.imgur_uploader_status_no_remaining_uploads); + uploadImageSection.setVisibility(GONE); + uploadUrlTextWrapper.setVisibility(GONE); + uploadProgressBar.setVisibility(GONE); + break; + } + } + + private enum State {CHOOSING, READY_TO_UPLOAD, UPLOADING, NO_UPLOAD_CREDITS} + +} diff --git a/Awful.apk/src/main/java/com/ferg/awfulapp/reply/Inserter.java b/Awful.apk/src/main/java/com/ferg/awfulapp/reply/Inserter.java index fad5c714e..b7b2786ab 100644 --- a/Awful.apk/src/main/java/com/ferg/awfulapp/reply/Inserter.java +++ b/Awful.apk/src/main/java/com/ferg/awfulapp/reply/Inserter.java @@ -25,7 +25,7 @@ abstract class Inserter { * Functional interface for the various untagged inserters */ interface Untagged { - void insert(@NonNull final EditText replyMessage, @NonNull final Activity activity); + void smartInsert(@NonNull final EditText replyMessage, @NonNull final Activity activity); } /** diff --git a/Awful.apk/src/main/java/com/ferg/awfulapp/reply/ListInserter.java b/Awful.apk/src/main/java/com/ferg/awfulapp/reply/ListInserter.java index edb47b2c4..2a8ce9dd2 100644 --- a/Awful.apk/src/main/java/com/ferg/awfulapp/reply/ListInserter.java +++ b/Awful.apk/src/main/java/com/ferg/awfulapp/reply/ListInserter.java @@ -1,7 +1,6 @@ package com.ferg.awfulapp.reply; import android.app.Activity; -import android.app.Dialog; import android.content.DialogInterface; import android.support.annotation.NonNull; import android.view.View; @@ -12,11 +11,11 @@ /** * Created by baka kaba on 26/09/2016. - * + *

* Handles inserting BBcode lists into an EditText. */ -public abstract class ListInserter extends Inserter { +abstract class ListInserter extends Inserter { // fixed ordering for the extra list type options, so we can check selected options by position // (and the labels can be changed/translated) @@ -32,20 +31,16 @@ public abstract class ListInserter extends Inserter { * @param replyMessage The wrapped text will be added here * @param activity The current Activity, used to display the dialog UI */ - public static void insert(@NonNull final EditText replyMessage, @NonNull final Activity activity) { + static void smartInsert(@NonNull final EditText replyMessage, @NonNull final Activity activity) { View layout = getDialogLayout(R.layout.insert_list_dialog, activity); final EditText textField = (EditText) layout.findViewById(R.id.list_items_field); final Spinner listTypeSpinner = (Spinner) layout.findViewById(R.id.list_type_spinner); setToSelection(textField, replyMessage); - DialogInterface.OnClickListener clickListener = new Dialog.OnClickListener() { - @Override - public void onClick(DialogInterface dialog, int which) { - int listTypeIndex = listTypeSpinner.getSelectedItemPosition(); - doInsert(replyMessage, textField.getText().toString(), listTypeIndex); - } + DialogInterface.OnClickListener clickListener = (dialog, which) -> { + int listTypeIndex = listTypeSpinner.getSelectedItemPosition(); + insertWithoutDialog(replyMessage, textField.getText().toString(), listTypeIndex); }; - getDialogBuilder(activity, layout, clickListener).setTitle("Insert list").show(); } @@ -60,7 +55,7 @@ public void onClick(DialogInterface dialog, int which) { * @param listItems The items in the list, separated by newlines * @param listTypeIndex A type constant used to format */ - private static void doInsert(@NonNull EditText replyMessage, @NonNull String listItems, int listTypeIndex) { + private static void insertWithoutDialog(@NonNull EditText replyMessage, @NonNull String listItems, int listTypeIndex) { // build the outer tags according to the selected list type String tagFormatString; switch (listTypeIndex) { diff --git a/Awful.apk/src/main/java/com/ferg/awfulapp/reply/MessageComposer.java b/Awful.apk/src/main/java/com/ferg/awfulapp/reply/MessageComposer.java index d6f3ae5b9..b905d2efa 100644 --- a/Awful.apk/src/main/java/com/ferg/awfulapp/reply/MessageComposer.java +++ b/Awful.apk/src/main/java/com/ferg/awfulapp/reply/MessageComposer.java @@ -125,22 +125,27 @@ public boolean onOptionsItemSelected(MenuItem item) { new EmoteFragment(this).show(getFragmentManager(), "emotes"); break; case R.id.bbcode_image: - insertWith(ImageInserter::insert); + insertWith(ImageInserter::smartInsert); + break; + case R.id.bbcode_imgur: + ImgurInserter imgurInserter = new ImgurInserter(); + imgurInserter.setTargetFragment(this, -1); + imgurInserter.show(getFragmentManager(), "imgur uploader"); break; case R.id.bbcode_video: - insertWith(VideoInserter::insert); + insertWith(VideoInserter::smartInsert); break; case R.id.bbcode_url: - insertWith(UrlInserter::insert); + insertWith(UrlInserter::smartInsert); break; case R.id.bbcode_quote: - insertWith(QuoteInserter::insert); + insertWith(QuoteInserter::smartInsert); break; case R.id.bbcode_list: - insertWith(ListInserter::insert); + insertWith(ListInserter::smartInsert); break; case R.id.bbcode_code: - insertWith(CodeInserter::insert); + insertWith(CodeInserter::smartInsert); break; case R.id.bbcode_pre: insertWith(BbCodeTag.PRE); @@ -153,14 +158,31 @@ public boolean onOptionsItemSelected(MenuItem item) { } private void insertWith(Inserter.Untagged inserter) { - inserter.insert(messageBox, getActivity()); + inserter.smartInsert(messageBox, getActivity()); } private void insertWith(BbCodeTag bbCodeTag) { - BasicTextInserter.insert(messageBox, bbCodeTag, getActivity()); + BasicTextInserter.smartInsert(messageBox, bbCodeTag, getActivity()); + } + + /////////////////////////////////////////////////////////////////////////// + // Callbacks + /////////////////////////////////////////////////////////////////////////// + + + public void onImageUploaded(@NonNull String url, boolean useThumbnail) { + ImageInserter.insertWithoutDialog(messageBox, url, useThumbnail); + } + + + public void onHtml5VideoUploaded(@NonNull String url) { + // embedding doesn't use [video] tags for some reason, needs to be a url with no link text + UrlInserter.insertWithoutDialog(messageBox, url, null); } + + /////////////////////////////////////////////////////////////////////////// // Useful public functions /////////////////////////////////////////////////////////////////////////// diff --git a/Awful.apk/src/main/java/com/ferg/awfulapp/reply/QuoteInserter.java b/Awful.apk/src/main/java/com/ferg/awfulapp/reply/QuoteInserter.java index 89559aad9..4a4b42ed4 100644 --- a/Awful.apk/src/main/java/com/ferg/awfulapp/reply/QuoteInserter.java +++ b/Awful.apk/src/main/java/com/ferg/awfulapp/reply/QuoteInserter.java @@ -11,11 +11,11 @@ /** * Created by baka kaba on 26/09/2016. - * + *

* Handles inserting BBcode quote blocks into an EditText. */ -public abstract class QuoteInserter extends Inserter { +abstract class QuoteInserter extends Inserter { /** * Display a dialog to insert a BBcode quote block. @@ -27,20 +27,16 @@ public abstract class QuoteInserter extends Inserter { * @param replyMessage The wrapped text will be added here * @param activity The current Activity, used to display the dialog UI */ - public static void insert(@NonNull final EditText replyMessage, @NonNull final Activity activity) { + static void smartInsert(@NonNull final EditText replyMessage, @NonNull final Activity activity) { View layout = getDialogLayout(R.layout.insert_quote_dialog, activity); final EditText sourceField = (EditText) layout.findViewById(R.id.source_field); final EditText textField = (EditText) layout.findViewById(R.id.text_field); setToSelection(textField, replyMessage); - DialogInterface.OnClickListener clickListener = new DialogInterface.OnClickListener() { - @Override - public void onClick(DialogInterface dialog, int which) { - String quoteSource = sourceField.getText().toString(); - doInsert(replyMessage, textField.getText().toString(), quoteSource.isEmpty() ? null : quoteSource); - } + DialogInterface.OnClickListener clickListener = (dialog, which) -> { + String quoteSource = sourceField.getText().toString(); + insertWithoutDialog(replyMessage, textField.getText().toString(), quoteSource.isEmpty() ? null : quoteSource); }; - getDialogBuilder(activity, layout, clickListener).setTitle("Insert quote").show(); } @@ -53,7 +49,7 @@ public void onClick(DialogInterface dialog, int which) { * @param quoteText The text of the quote * @param quoteSource An optional quote source */ - private static void doInsert(@NonNull EditText replyMessage, @NonNull String quoteText, @Nullable String quoteSource) { + private static void insertWithoutDialog(@NonNull EditText replyMessage, @NonNull String quoteText, @Nullable String quoteSource) { // add the quote's source as a parameter if we have one String sourceParam = quoteSource == null ? "" : "=\"" + quoteSource + "\""; String bbCode = String.format("%n[quote%s]%n%s%n[/quote]%n", sourceParam, quoteText); diff --git a/Awful.apk/src/main/java/com/ferg/awfulapp/reply/UrlInserter.java b/Awful.apk/src/main/java/com/ferg/awfulapp/reply/UrlInserter.java index d0c9e27a8..c6883c947 100644 --- a/Awful.apk/src/main/java/com/ferg/awfulapp/reply/UrlInserter.java +++ b/Awful.apk/src/main/java/com/ferg/awfulapp/reply/UrlInserter.java @@ -3,18 +3,21 @@ import android.app.Activity; import android.content.DialogInterface; import android.support.annotation.NonNull; +import android.support.annotation.Nullable; import android.view.View; import android.widget.EditText; import com.ferg.awfulapp.R; +import org.apache.commons.lang3.StringUtils; + /** * Created by baka kaba on 25/09/2016. *

* Handles inserting BBcode URL tags into an EditText. */ -public abstract class UrlInserter extends Inserter { +abstract class UrlInserter extends Inserter { /** * Display a dialog to insert a URL. @@ -31,7 +34,7 @@ public abstract class UrlInserter extends Inserter { * @param replyMessage The wrapped text will be added here * @param activity The current Activity, used to display the dialog UI */ - public static void insert(@NonNull final EditText replyMessage, @NonNull final Activity activity) { + static void smartInsert(@NonNull final EditText replyMessage, @NonNull final Activity activity) { View layout = getDialogLayout(R.layout.insert_url_dialog, activity); final EditText urlField = (EditText) layout.findViewById(R.id.url_field); final EditText textField = (EditText) layout.findViewById(R.id.text_field); @@ -48,29 +51,25 @@ public static void insert(@NonNull final EditText replyMessage, @NonNull final A } } - DialogInterface.OnClickListener clickListener = new DialogInterface.OnClickListener() { - - @Override - public void onClick(DialogInterface dialog, int which) { - // if the link text is blank, use the URL - String linkText = textField.getText().toString(); - String url = urlField.getText().toString(); - doInsert(replyMessage, url, linkText.isEmpty() ? url : linkText); - } - }; + DialogInterface.OnClickListener clickListener = (dialog, which) -> + insertWithoutDialog(replyMessage, urlField.getText().toString(), textField.getText().toString()); getDialogBuilder(activity, layout, clickListener).setTitle("Insert URL").show(); } /** * Perform the insertion. + *

+ * If (non-empty) link text is provided then the url is added in the opening tag, otherwise + * the url is added between the tags. * * @param replyMessage The reply message being edited * @param url The URL of the link - * @param linkText The text to display + * @param linkText Optional text to display */ - private static void doInsert(@NonNull EditText replyMessage, @NonNull String url, @NonNull String linkText) { - String bbCode = String.format("[url=\"%s\"]%s[/url]", url, linkText); + static void insertWithoutDialog(@NonNull EditText replyMessage, @NonNull String url, @Nullable String linkText) { + String formatString = StringUtils.isEmpty(linkText) ? "[url]%s[/url]" : "[url=\"%s\"]%s[/url]"; + String bbCode = String.format(formatString, url, linkText); insertIntoReply(replyMessage, bbCode); } diff --git a/Awful.apk/src/main/java/com/ferg/awfulapp/reply/VideoInserter.java b/Awful.apk/src/main/java/com/ferg/awfulapp/reply/VideoInserter.java index 42051a356..e879a5a10 100644 --- a/Awful.apk/src/main/java/com/ferg/awfulapp/reply/VideoInserter.java +++ b/Awful.apk/src/main/java/com/ferg/awfulapp/reply/VideoInserter.java @@ -1,7 +1,6 @@ package com.ferg.awfulapp.reply; import android.app.Activity; -import android.app.Dialog; import android.content.DialogInterface; import android.support.annotation.NonNull; import android.view.View; @@ -18,7 +17,7 @@ * Handles inserting BBcode video tags into an EditText. */ -public abstract class VideoInserter extends Inserter { +abstract class VideoInserter extends Inserter { /** * Display a dialog to insert a video. @@ -32,7 +31,7 @@ public abstract class VideoInserter extends Inserter { * @param replyMessage The wrapped text will be added here * @param activity The current Activity, used to display the dialog UI */ - public static void insert(@NonNull final EditText replyMessage, @NonNull final Activity activity) { + static void smartInsert(@NonNull final EditText replyMessage, @NonNull final Activity activity) { View layout = getDialogLayout(R.layout.insert_video_dialog, activity); final EditText urlField = (EditText) layout.findViewById(R.id.url_field); @@ -45,13 +44,7 @@ public static void insert(@NonNull final EditText replyMessage, @NonNull final A setText(urlField, clipboardText); } - DialogInterface.OnClickListener clickListener = new Dialog.OnClickListener() { - @Override - public void onClick(DialogInterface dialog, int which) { - doInsert(replyMessage, urlField.getText().toString()); - } - }; - + DialogInterface.OnClickListener clickListener = (dialog, which) -> insertWithoutDialog(replyMessage, urlField.getText().toString()); getDialogBuilder(activity, layout, clickListener).setTitle("Insert video").show(); } @@ -64,13 +57,14 @@ public void onClick(DialogInterface dialog, int which) { * @param replyMessage The reply message being edited * @param videoUrl the URL to add to the tag */ - private static void doInsert(@NonNull EditText replyMessage, @NonNull String videoUrl) { + static void insertWithoutDialog(@NonNull EditText replyMessage, @NonNull String videoUrl) { videoUrl = sanitiseUrl(videoUrl); final String bbCodeTemplate = "%n[video]%s[/video]%n"; String bbCode = String.format(bbCodeTemplate, videoUrl); insertIntoReply(replyMessage, bbCode); } + /** * Handle any annoying URLs the site can't manage, i.e. the mobile youtu.be/lol stuff * diff --git a/Awful.apk/src/main/java/com/ferg/awfulapp/task/ImgurUploadRequest.java b/Awful.apk/src/main/java/com/ferg/awfulapp/task/ImgurUploadRequest.java new file mode 100644 index 000000000..3565eb643 --- /dev/null +++ b/Awful.apk/src/main/java/com/ferg/awfulapp/task/ImgurUploadRequest.java @@ -0,0 +1,354 @@ +package com.ferg.awfulapp.task; + +import android.content.SharedPreferences; +import android.os.Build; +import android.support.annotation.NonNull; +import android.support.annotation.Nullable; +import android.support.v4.util.ArrayMap; +import android.util.Log; +import android.util.Pair; + +import com.android.volley.AuthFailureError; +import com.android.volley.DefaultRetryPolicy; +import com.android.volley.NetworkResponse; +import com.android.volley.Request; +import com.android.volley.Response; +import com.android.volley.VolleyError; +import com.android.volley.toolbox.HttpHeaderParser; +import com.ferg.awfulapp.AwfulApplication; +import com.ferg.awfulapp.R; +import com.ferg.awfulapp.constants.Constants; +import com.ferg.awfulapp.preferences.AwfulPreferences; + +import org.apache.http.HttpEntity; +import org.apache.http.entity.ContentType; +import org.apache.http.entity.mime.MultipartEntityBuilder; +import org.apache.http.entity.mime.content.StringBody; +import org.json.JSONException; +import org.json.JSONObject; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.UnsupportedEncodingException; +import java.util.Map; +import java.util.concurrent.TimeUnit; + +/** + * Created by baka kaba on 31/05/2017. + *

+ * Handles requests to upload images to the Imgur API, and tracks quotas. + *

+ * We handle two kinds of uploads - URLs (basically a link to an image hosted elsewhere), and an + * actual file upload from the user's device. File uploads take an InputStream, which lets you use + * the OS's content picker functionality to upload from sources like cloud providers instead of just + * local files. The response JSON is returned, see the API docs for what this contains. + *

+ * Imgur operates a credit system, so we need to keep track of the user and app limits and prevent + * uploads when they're hit. Unfortunately we can't request this information (without spending credits) + * so we need to store the latest data from the last response, and expire that when it becomes stale. + * This isn't perfect (e.g. the credits for the entire app will probably have changed no matter what) + * but it's a compromise we need to make. + *

+ * Imgur doesn't allow many credits on the free tier - currently 12,500, enough for 1,250 upload attempts. + * There's rate limiting for each user, but it's 500 credits per hour, so the daily app limit is only 25x + * that. The user limit is implemented (in case it changes and starts to affect things), but we also have + * our own daily user quota which will cut people off way earlier. This is to make sure a handful of users + * can't spend all the app credits - the actual limit will probably need tweaking. + *

+ * Also hitting the app credit limit too many times in a month will get us kicked off for the rest + * of the month, so there's a buffer setting which effectively lowers the credit limit to block uploads + * early. Unfortunately this only works when we know the current app credits, and if we don't have that + * data (because we haven't uploaded in the current period) then we have to blindly attempt the upload + * and hope we have credits. If the buffer isn't big enough and we end up with too many blind requests + * pushing us over the limit, then this will need to be changed - maybe by making a simple GET to get + * the current value when we don't have data. Slightly complicates things, but it might need to be done. + *

+ * The {@link #getCurrentUploadLimit()} method attempts to provide the currently applicable limit + * (i.e. the lowest one) and the time when that limit will reset. Because there are multiple limits + * (some not directly affected by the user) this could be a bit confusing, with allowed uploads + * decreasing between uses (if the app limit is close to being hit), 'resets' giving inconsistent + * numbers (if another limit is now lower and taking precedence), etc. It might actually be better + * to not give the user any indication of how many uploads they can perform if it's too wild, but + * I think it's good to at least have an idea of what's going on, especially if you want to write a + * post and have a few things to include. + * + * @see https://apidocs.imgur.com + */ +public class ImgurUploadRequest extends Request { + + public static final String TAG = ImgurUploadRequest.class.getSimpleName(); + + // keys for persisted data on last-known limits and when they're guaranteed to expire + private static final String KEY_USER_CREDITS = TAG + ".user_credits"; + private static final String KEY_USER_CREDITS_EXPIRY = TAG + ".user_credits_expiry"; + + private static final String KEY_APP_CREDITS = TAG + ".app_credits"; + private static final String KEY_APP_CREDITS_EXPIRY = TAG + ".app_credits_expiry"; + + private static final String KEY_USER_QUOTA_REMAINING = TAG + ".user_quota_remaining"; + private static final String KEY_USER_QUOTA_EXPIRY = TAG + ".user_quota_expiry"; + + /** + * The cost of an upload according to the Imgur API + */ + private static final int CREDITS_PER_UPLOAD = 10; + /** + * The (approximate) frequency of app credit resets - used to invalidate old data + */ + private static final long APP_CREDIT_LIMIT_PERIOD = TimeUnit.DAYS.toMillis(1); + /** + * Used to lower the app credit limit, to avoid hitting the full limit (and getting the app banned) + */ + private static final int APP_CREDIT_BUFFER = Constants.DEBUG ? 500 : 1000; // dev privilege + + /** + * Our own quota period, we reset daily + */ + private static final long USER_CREDIT_QUOTA_PERIOD = TimeUnit.DAYS.toMillis(1); + /** + * Our own per-user daily upload quota, to stop a handful of users spending all the app's credits + */ + private static final int USER_CREDIT_QUOTA_MAX = Constants.DEBUG ? Integer.MAX_VALUE : 500; // dev privilege + + private static final String IMGUR_ENDPOINT_URL = "https://api.imgur.com/3/image"; + + private final MultipartEntityBuilder attachParams = MultipartEntityBuilder.create(); + private final Response.Listener jsonResponseListener; + private HttpEntity httpEntity; + + + /////////////////////////////////////////////////////////////////////////// + // Constructors + /////////////////////////////////////////////////////////////////////////// + + /** + * Create an upload request with its basic parameters. + * + * @param isFile true if we're uploading file data, false if we're providing a URL + */ + private ImgurUploadRequest(boolean isFile, + @NonNull Response.Listener jsonResponseListener, + @Nullable Response.ErrorListener errorListener) { + super(Method.POST, IMGUR_ENDPOINT_URL, errorListener); + this.jsonResponseListener = jsonResponseListener; + setRetryPolicy(new DefaultRetryPolicy(20000, 1, 1)); + attachParams.addPart("type", new StringBody(isFile ? "file" : "URL", ContentType.TEXT_PLAIN)); + } + + + /** + * Upload an image to Imgur as data via an InputStream. + * + * @param imageStream the image data + * @param jsonResponseListener receives the response data from Imgur + */ + public ImgurUploadRequest(@NonNull InputStream imageStream, + @NonNull Response.Listener jsonResponseListener, + @Nullable Response.ErrorListener errorListener) { + this(true, jsonResponseListener, errorListener); + attachParams.addBinaryBody("image", imageStream); + httpEntity = attachParams.build(); + } + + + /** + * Host an existing online image on Imgur by passing its URL. + * + * @param sourceUrl the URL of the online image + * @param jsonResponseListener receives the response data from Imgur + */ + public ImgurUploadRequest(@NonNull String sourceUrl, + @NonNull Response.Listener jsonResponseListener, + @Nullable Response.ErrorListener errorListener) { + this(false, jsonResponseListener, errorListener); + attachParams.addPart("image", new StringBody(sourceUrl, ContentType.TEXT_PLAIN)); + httpEntity = attachParams.build(); + } + + + /////////////////////////////////////////////////////////////////////////// + // Request data + /////////////////////////////////////////////////////////////////////////// + + @Override + public Map getHeaders() throws AuthFailureError { + String clientId = AwfulPreferences.getInstance().getResources().getString(R.string.imgur_api_client_id); + Map headers = new ArrayMap<>(1); + headers.put("Authorization", "Client-ID " + clientId); + return headers; + } + + + @Override + public String getBodyContentType() { + return httpEntity.getContentType().getValue(); + } + + + @Override + public byte[] getBody() throws AuthFailureError { + try { + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + httpEntity.writeTo(bytes); + return bytes.toByteArray(); + } catch (IOException ioe) { + Log.e(TAG, "Failed to convert body ByteStream"); + } + return super.getBody(); + } + + + /////////////////////////////////////////////////////////////////////////// + // Response handling + /////////////////////////////////////////////////////////////////////////// + + @Override + protected Response parseNetworkResponse(NetworkResponse response) { + updateCredits(response); + try { + String json = new String(response.data, + HttpHeaderParser.parseCharset(response.headers)); + return Response.success(new JSONObject(json), HttpHeaderParser.parseCacheHeaders(response)); + } catch (UnsupportedEncodingException | JSONException e) { + e.printStackTrace(); + } + return null; + } + + + @Override + protected VolleyError parseNetworkError(VolleyError volleyError) { + updateCredits(volleyError.networkResponse); + return super.parseNetworkError(volleyError); + } + + + @Override + protected void deliverResponse(JSONObject response) { + jsonResponseListener.onResponse(response); + } + + + /////////////////////////////////////////////////////////////////////////// + // Upload credits + /////////////////////////////////////////////////////////////////////////// + + + /** + * Get the best estimate of the remaining number of uploads the user can perform, and when that limit resets. + *

+ * Imgur sets limits on the number of basic API requests and actual uploads each app and user + * can do. In addition, this app also limits user uploads to ration the overall app limit, and + * prevent individual users from overdoing it at the expense of others. This method provides + * the best guess at how many full uploads the user can perform, and a timestamp of when + * that limit will be reset. + *

+ * Because API requests cost credits, we don't actually ask the server how many credits + * are remaining for the app and the user - upload responses provide this info, and we store that + * when the user attempts an upload. If we don't have current data for these limits (e.g. the + * most recent data is stale) then we can't draw any meaningful conclusions about the current + * situation - in this case the method returns null for the count and timestamp. + *

+ * If we have this data, then we return the lowest limit in place, along with the timestamp for + * when we expect this limit to change (which may be null if we can't estimate that, e.g. the app + * credit limit resets at an unknown time, but apparently within a day). This is complicated by + * the fact that another limit might drop below this one - say if other users drain all the app + * credits - and cause a new reset time to apply, or the reset might happen (e.g. your personal + * quota is restored) but now another limit is lower (the total app limit), so the reset doesn't + * seem to have applied properly. + *

+ * Basically this is complicated with multiple restrictions in place, some happening at a distance + * and affected by other users, and we're having to walk around in the dark trying not to touch + * the API too much. So use this in an advisory capacity only! + * + * @return an upload count / reset timestamp pair, both potentially null if we don't have that data + */ + @NonNull + public static Pair getCurrentUploadLimit() { + SharedPreferences appStatePrefs = AwfulApplication.getAppStatePrefs(); + long now = System.currentTimeMillis(); + + long appCreditsExpiry = appStatePrefs.getLong(KEY_APP_CREDITS_EXPIRY, -1); + Integer appCredits = appCreditsExpiry < now ? null : appStatePrefs.getInt(KEY_APP_CREDITS, 0); + + long userCreditsExpiry = appStatePrefs.getLong(KEY_USER_CREDITS_EXPIRY, -1); + Integer userCredits = userCreditsExpiry < now ? null : appStatePrefs.getInt(KEY_USER_CREDITS, 0); + + // if either of these credits values are null (i.e. no current data) then we can't give any meaningful estimates + if (appCredits == null || userCredits == null) { + return new Pair<>(null, null); + } + + // the quota is the in-app limit on a user's uploads, since we (currently) only get 1250 pics' worth TOTAL per day + // we manage and reset this ourselves, so it's the only count we can be absolutely sure about + long userQuotaExpiry = appStatePrefs.getLong(KEY_USER_QUOTA_EXPIRY, -1); + int userQuotaRemaining = userQuotaExpiry < now ? USER_CREDIT_QUOTA_MAX : appStatePrefs.getInt(KEY_USER_QUOTA_REMAINING, USER_CREDIT_QUOTA_MAX); + + // return the minimum upload limit, and the time it expires (if appropriate) + if (appCredits < userCredits && appCredits < userQuotaRemaining) { + // we don't know exactly when the app's credits will be reset, so don't pass a timestamp + return new Pair<>(appCredits / CREDITS_PER_UPLOAD, null); + } else if (userCredits < userQuotaRemaining) { + return new Pair<>(userCredits / CREDITS_PER_UPLOAD, userCreditsExpiry); + } else { + return new Pair<>(userQuotaRemaining / CREDITS_PER_UPLOAD, userQuotaExpiry); + } + } + + + /** + * Parse the request response and extract the current API credits data. + * + * @param response the response returned by the Imgur API + */ + private static void updateCredits(@Nullable NetworkResponse response) { + // every attempt (successful or not) costs credits! + subtractUploadFromQuota(); + + if (response == null) { + return; + } + try { + int userUploadCredits = Integer.parseInt(response.headers.get("X-RateLimit-UserRemaining")); + int appUploadCredits = Integer.parseInt(response.headers.get("X-RateLimit-ClientRemaining")); + long userCreditResetTimestamp = Long.parseLong(response.headers.get("X-RateLimit-UserReset")) * 1000L; // API timestamp is in seconds + // only update the prefs when we've successfully parsed everything + AwfulApplication.getAppStatePrefs().edit() + .putInt(KEY_USER_CREDITS, userUploadCredits) + .putLong(KEY_USER_CREDITS_EXPIRY, userCreditResetTimestamp) + .putInt(KEY_APP_CREDITS, appUploadCredits - APP_CREDIT_BUFFER) // record a lower number of total credits to provide some safety + .putLong(KEY_APP_CREDITS_EXPIRY, System.currentTimeMillis() + APP_CREDIT_LIMIT_PERIOD) + .apply(); + } catch (NumberFormatException | NullPointerException e) { + // TODO: 05/06/2017 failed to update something - block uploads/checks for a while? + Log.w(TAG, "updateCredits: failed to parse response!", e); + } + } + + + /** + * Remove one upload's worth of credits from the current user quota. + *

+ * This will reset the quota if it has expired (the reset window has passed) before subtracting, + * restoring the quota to its maximum and updating the expiry timestamp relative to now. + */ + private static void subtractUploadFromQuota() { + SharedPreferences appStatePrefs = AwfulApplication.getAppStatePrefs(); + long now = System.currentTimeMillis(); + int currentQuota = appStatePrefs.getInt(KEY_USER_QUOTA_REMAINING, USER_CREDIT_QUOTA_MAX); + long quotaExpiryTime = appStatePrefs.getLong(KEY_USER_QUOTA_EXPIRY, -1); + + SharedPreferences.Editor editor = appStatePrefs.edit(); + if (quotaExpiryTime < now) { + // quota has expired, reset it and set the new expiry time + editor.putInt(KEY_USER_QUOTA_REMAINING, USER_CREDIT_QUOTA_MAX - CREDITS_PER_UPLOAD); + editor.putLong(KEY_USER_QUOTA_EXPIRY, now + USER_CREDIT_QUOTA_PERIOD); + } else { + editor.putInt(KEY_USER_QUOTA_REMAINING, currentQuota - CREDITS_PER_UPLOAD); + } + editor.apply(); + } + + +} diff --git a/Awful.apk/src/main/java/com/ferg/awfulapp/thread/ThreadDisplay.java b/Awful.apk/src/main/java/com/ferg/awfulapp/thread/ThreadDisplay.java index 19f1bd955..cb64069ae 100644 --- a/Awful.apk/src/main/java/com/ferg/awfulapp/thread/ThreadDisplay.java +++ b/Awful.apk/src/main/java/com/ferg/awfulapp/thread/ThreadDisplay.java @@ -30,6 +30,7 @@ */ public abstract class ThreadDisplay { + // TODO: 16/08/2017 generate this automatically from the folder contents /** * All the scripts from the javascript folder used in generating HTML */ @@ -39,11 +40,14 @@ public abstract class ThreadDisplay { "zepto/fx.js", "zepto/fx_methods.js", "zepto/touch.js", + "zepto/deferred.js", + "zepto/callbacks.js", "scrollend.js", "inviewport.js", "json2.js", "twitterwidget.js", "salr.js", + "embedding.js", "thread.js" }; diff --git a/Awful.apk/src/main/java/com/ferg/awfulapp/webview/WebViewJsInterface.java b/Awful.apk/src/main/java/com/ferg/awfulapp/webview/WebViewJsInterface.java index 3e69c5d64..81afe5a1d 100644 --- a/Awful.apk/src/main/java/com/ferg/awfulapp/webview/WebViewJsInterface.java +++ b/Awful.apk/src/main/java/com/ferg/awfulapp/webview/WebViewJsInterface.java @@ -5,6 +5,7 @@ import android.webkit.JavascriptInterface; import com.ferg.awfulapp.preferences.AwfulPreferences; +import com.ferg.awfulapp.preferences.Keys; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; @@ -41,6 +42,8 @@ public final void updatePreferences() { preferences.put("highlightUserQuote", Boolean.toString(aPrefs.highlightUserQuote)); preferences.put("highlightUsername", Boolean.toString(aPrefs.highlightUsername)); preferences.put("inlineTweets", Boolean.toString(aPrefs.inlineTweets)); + preferences.put("inlineInstagram", Boolean.toString(aPrefs.getPreference(Keys.INLINE_INSTAGRAM, false))); + preferences.put("inlineTwitch", Boolean.toString(aPrefs.getPreference(Keys.INLINE_TWITCH, false))); preferences.put("inlineWebm", Boolean.toString(aPrefs.inlineWebm)); preferences.put("autostartWebm", Boolean.toString(aPrefs.autostartWebm)); preferences.put("inlineVines", Boolean.toString(aPrefs.inlineVines)); diff --git a/Awful.apk/src/main/java/com/ferg/awfulapp/widget/StatusFrog.java b/Awful.apk/src/main/java/com/ferg/awfulapp/widget/StatusFrog.java new file mode 100644 index 000000000..c88db608d --- /dev/null +++ b/Awful.apk/src/main/java/com/ferg/awfulapp/widget/StatusFrog.java @@ -0,0 +1,94 @@ +package com.ferg.awfulapp.widget; + +import android.annotation.TargetApi; +import android.content.Context; +import android.content.res.TypedArray; +import android.os.Build; +import android.support.annotation.Nullable; +import android.support.annotation.StringRes; +import android.util.AttributeSet; +import android.view.LayoutInflater; +import android.view.View; +import android.widget.ProgressBar; +import android.widget.RelativeLayout; +import android.widget.TextView; + +import com.ferg.awfulapp.R; + +import butterknife.BindView; +import butterknife.ButterKnife; + +/** + * Created by baka kaba on 14/08/2017. + *

+ * A widget that displays a status message and an optional activity spinner, with a frog icon in the + * background. + *

+ * This is meant for use on blank areas where you need to explain there's no content, the user needs + * to do something (like selecting an item in a master-detail layout), or show the current status and + * activity (e.g. content is being fetched). + */ +public class StatusFrog extends RelativeLayout { + + @BindView(R.id.status_message) + TextView statusMessage; + @BindView(R.id.status_progress_bar) + ProgressBar progressBar; + + public StatusFrog(Context context) { + super(context); + init(context, null); + } + + public StatusFrog(Context context, AttributeSet attrs) { + super(context, attrs); + init(context, attrs); + } + + public StatusFrog(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + init(context, attrs); + } + + @TargetApi(Build.VERSION_CODES.LOLLIPOP) + public StatusFrog(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) { + super(context, attrs, defStyleAttr, defStyleRes); + init(context, attrs); + } + + + private void init(Context context, @Nullable AttributeSet attrs) { + View view = LayoutInflater.from(context).inflate(R.layout.status_frog, this, true); + ButterKnife.bind(this, view); + // handle any custom XML attributes + if (attrs != null) { + TypedArray typedArray = context.getTheme().obtainStyledAttributes(attrs, R.styleable.StatusFrog, 0, 0); + setStatusText(typedArray.getString(R.styleable.StatusFrog_status_message)); + showSpinner(typedArray.getBoolean(R.styleable.StatusFrog_show_spinner, false)); + typedArray.recycle(); + } + } + + + /////////////////////////////////////////////////////////////////////////// + // Update methods + /////////////////////////////////////////////////////////////////////////// + + + public StatusFrog setStatusText(@Nullable String text) { + statusMessage.setText((text == null) ? "" : text); + return this; + } + + public StatusFrog setStatusText(@StringRes int resId) { + return setStatusText(getContext().getString(resId)); + } + + /** + * Display or hide the activity spinner. + */ + public StatusFrog showSpinner(boolean show) { + progressBar.setVisibility(show ? VISIBLE : INVISIBLE); + return this; + } +} diff --git a/Awful.apk/src/main/res/drawable-hdpi/frog_icon.png b/Awful.apk/src/main/res/drawable-hdpi/frog_icon.png new file mode 100644 index 000000000..d3e6d0ef6 Binary files /dev/null and b/Awful.apk/src/main/res/drawable-hdpi/frog_icon.png differ diff --git a/Awful.apk/src/main/res/drawable-mdpi/frog_icon.png b/Awful.apk/src/main/res/drawable-mdpi/frog_icon.png new file mode 100644 index 000000000..c7f3d477f Binary files /dev/null and b/Awful.apk/src/main/res/drawable-mdpi/frog_icon.png differ diff --git a/Awful.apk/src/main/res/drawable-xhdpi/frog_icon.png b/Awful.apk/src/main/res/drawable-xhdpi/frog_icon.png new file mode 100644 index 000000000..eaf85acc3 Binary files /dev/null and b/Awful.apk/src/main/res/drawable-xhdpi/frog_icon.png differ diff --git a/Awful.apk/src/main/res/drawable-xxhdpi/frog_icon.png b/Awful.apk/src/main/res/drawable-xxhdpi/frog_icon.png new file mode 100644 index 000000000..c57d89358 Binary files /dev/null and b/Awful.apk/src/main/res/drawable-xxhdpi/frog_icon.png differ diff --git a/Awful.apk/src/main/res/drawable-xxxhdpi/frog_icon.png b/Awful.apk/src/main/res/drawable-xxxhdpi/frog_icon.png new file mode 100644 index 000000000..c8ef06cb9 Binary files /dev/null and b/Awful.apk/src/main/res/drawable-xxxhdpi/frog_icon.png differ diff --git a/Awful.apk/src/main/res/drawable/ic_cloud_upload_dark.xml b/Awful.apk/src/main/res/drawable/ic_cloud_upload_dark.xml new file mode 100644 index 000000000..5c30e6461 --- /dev/null +++ b/Awful.apk/src/main/res/drawable/ic_cloud_upload_dark.xml @@ -0,0 +1,9 @@ + + + diff --git a/Awful.apk/src/main/res/layout-land/settings.xml b/Awful.apk/src/main/res/layout-land/settings.xml index bf6a46192..a73a5e751 100644 --- a/Awful.apk/src/main/res/layout-land/settings.xml +++ b/Awful.apk/src/main/res/layout-land/settings.xml @@ -1,26 +1,26 @@ + xmlns:app="http://schemas.android.com/apk/res-auto" + android:id="@+id/layout_main" + android:layout_width="match_parent" + android:layout_height="match_parent" + android:background="?attr/background" + android:orientation="vertical"> + app:popupTheme="?attr/awfulPopUpTheme"/> @@ -28,16 +28,20 @@ + + diff --git a/Awful.apk/src/main/res/layout-sw600dp/private_message_activity.xml b/Awful.apk/src/main/res/layout-sw600dp/private_message_activity.xml index 732dc17ea..e41e44b2c 100644 --- a/Awful.apk/src/main/res/layout-sw600dp/private_message_activity.xml +++ b/Awful.apk/src/main/res/layout-sw600dp/private_message_activity.xml @@ -1,7 +1,6 @@ + android:baselineAligned="false" + android:orientation="horizontal"> + android:layout_weight="2" + android:orientation="horizontal"> - - - - - + android:layout_gravity="center" + app:show_spinner="false" + app:status_message="@string/select_message" + /> + + diff --git a/Awful.apk/src/main/res/layout/announcements_fragment.xml b/Awful.apk/src/main/res/layout/announcements_fragment.xml index b109ac632..aaa6a10e3 100644 --- a/Awful.apk/src/main/res/layout/announcements_fragment.xml +++ b/Awful.apk/src/main/res/layout/announcements_fragment.xml @@ -1,18 +1,26 @@ - + - + - \ No newline at end of file + android:layout_height="match_parent" + android:visibility="invisible" + /> + + \ No newline at end of file diff --git a/Awful.apk/src/main/res/layout/announcements_activity.xml b/Awful.apk/src/main/res/layout/basic_activity.xml similarity index 73% rename from Awful.apk/src/main/res/layout/announcements_activity.xml rename to Awful.apk/src/main/res/layout/basic_activity.xml index 171b6ff9a..f9e2af25a 100644 --- a/Awful.apk/src/main/res/layout/announcements_activity.xml +++ b/Awful.apk/src/main/res/layout/basic_activity.xml @@ -1,7 +1,6 @@ - + android:layout_height="match_parent"/> \ No newline at end of file diff --git a/Awful.apk/src/main/res/layout/emote_grid_item.xml b/Awful.apk/src/main/res/layout/emote_grid_item.xml index 3ce3bcfdd..00a82f236 100644 --- a/Awful.apk/src/main/res/layout/emote_grid_item.xml +++ b/Awful.apk/src/main/res/layout/emote_grid_item.xml @@ -13,7 +13,7 @@ android:minWidth="32dp" android:paddingBottom="2dp" android:scaleType="fitCenter" - android:src="@mipmap/ic_launcher" /> + android:src="@drawable/frog_icon" /> @@ -19,40 +18,13 @@ android:layout_height="match_parent" android:layout_below="@+id/probation_bar"> - - - - - - - - - - + + + + + \ No newline at end of file diff --git a/Awful.apk/src/main/res/layout/imageview_activity.xml b/Awful.apk/src/main/res/layout/imageview_activity.xml deleted file mode 100644 index b554a9d7c..000000000 --- a/Awful.apk/src/main/res/layout/imageview_activity.xml +++ /dev/null @@ -1,29 +0,0 @@ - - - - - - - \ No newline at end of file diff --git a/Awful.apk/src/main/res/layout/insert_imgur_dialog.xml b/Awful.apk/src/main/res/layout/insert_imgur_dialog.xml new file mode 100644 index 000000000..51a5441d6 --- /dev/null +++ b/Awful.apk/src/main/res/layout/insert_imgur_dialog.xml @@ -0,0 +1,232 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Awful.apk/src/main/res/layout/nav_drawer_header.xml b/Awful.apk/src/main/res/layout/nav_drawer_header.xml index ae39052ff..e4093c96e 100644 --- a/Awful.apk/src/main/res/layout/nav_drawer_header.xml +++ b/Awful.apk/src/main/res/layout/nav_drawer_header.xml @@ -11,7 +11,7 @@ android:layout_width="64dp" android:layout_height="64dp" android:elevation="4dp" - android:src="@mipmap/ic_launcher" + android:src="@drawable/frog_icon" android:background="@drawable/avatar_clipping_circle" android:layout_alignParentBottom="true" android:layout_marginLeft="16dp" diff --git a/Awful.apk/src/main/res/layout/status_frog.xml b/Awful.apk/src/main/res/layout/status_frog.xml new file mode 100644 index 000000000..dde3a15d7 --- /dev/null +++ b/Awful.apk/src/main/res/layout/status_frog.xml @@ -0,0 +1,36 @@ + + + + + + + + + diff --git a/Awful.apk/src/main/res/menu/insert_into_message.xml b/Awful.apk/src/main/res/menu/insert_into_message.xml index c6d2cf9d0..cd382fbd5 100644 --- a/Awful.apk/src/main/res/menu/insert_into_message.xml +++ b/Awful.apk/src/main/res/menu/insert_into_message.xml @@ -8,6 +8,10 @@ android:id="@+id/bbcode_image" android:icon="@drawable/ic_panorama_dark" android:title="@string/reply_img_tag"/> + + + + + \ No newline at end of file diff --git a/Awful.apk/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/Awful.apk/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 000000000..036d09bc5 --- /dev/null +++ b/Awful.apk/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/Awful.apk/src/main/res/mipmap-hdpi/ic_launcher_foreground.png b/Awful.apk/src/main/res/mipmap-hdpi/ic_launcher_foreground.png new file mode 100644 index 000000000..b65b07b5d Binary files /dev/null and b/Awful.apk/src/main/res/mipmap-hdpi/ic_launcher_foreground.png differ diff --git a/Awful.apk/src/main/res/mipmap-hdpi/ic_launcher_round.png b/Awful.apk/src/main/res/mipmap-hdpi/ic_launcher_round.png new file mode 100644 index 000000000..901a13d8c Binary files /dev/null and b/Awful.apk/src/main/res/mipmap-hdpi/ic_launcher_round.png differ diff --git a/Awful.apk/src/main/res/mipmap-mdpi/ic_launcher_foreground.png b/Awful.apk/src/main/res/mipmap-mdpi/ic_launcher_foreground.png new file mode 100644 index 000000000..ef2eee36c Binary files /dev/null and b/Awful.apk/src/main/res/mipmap-mdpi/ic_launcher_foreground.png differ diff --git a/Awful.apk/src/main/res/mipmap-mdpi/ic_launcher_round.png b/Awful.apk/src/main/res/mipmap-mdpi/ic_launcher_round.png new file mode 100644 index 000000000..ba56cd469 Binary files /dev/null and b/Awful.apk/src/main/res/mipmap-mdpi/ic_launcher_round.png differ diff --git a/Awful.apk/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png b/Awful.apk/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png new file mode 100644 index 000000000..d7f97cacd Binary files /dev/null and b/Awful.apk/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png differ diff --git a/Awful.apk/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/Awful.apk/src/main/res/mipmap-xhdpi/ic_launcher_round.png new file mode 100644 index 000000000..5436f4923 Binary files /dev/null and b/Awful.apk/src/main/res/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/Awful.apk/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png b/Awful.apk/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png new file mode 100644 index 000000000..feb17d715 Binary files /dev/null and b/Awful.apk/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png differ diff --git a/Awful.apk/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/Awful.apk/src/main/res/mipmap-xxhdpi/ic_launcher_round.png new file mode 100644 index 000000000..07490567e Binary files /dev/null and b/Awful.apk/src/main/res/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/Awful.apk/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png b/Awful.apk/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png new file mode 100644 index 000000000..0d7ff22de Binary files /dev/null and b/Awful.apk/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png differ diff --git a/Awful.apk/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/Awful.apk/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png new file mode 100644 index 000000000..8347d184e Binary files /dev/null and b/Awful.apk/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/Awful.apk/src/main/res/values/attrs.xml b/Awful.apk/src/main/res/values/attrs.xml new file mode 100644 index 000000000..c0e25051a --- /dev/null +++ b/Awful.apk/src/main/res/values/attrs.xml @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/Awful.apk/src/main/res/values/changelog.xml b/Awful.apk/src/main/res/values/changelog.xml index 6407e24c4..4a83398ad 100644 --- a/Awful.apk/src/main/res/values/changelog.xml +++ b/Awful.apk/src/main/res/values/changelog.xml @@ -1,10 +1,17 @@ + + 3.4.1:\n + - Oreo support including adaptive icons that jiggle around or something\n\n + - Imgur uploading - limited uploads per day for now\n\n + - Instagram embedding, and -very- experimental Twitch embeds\n\n + - Settings gets some visual tweaks and fixes + 3.3.3:\n - Fix for HTTPS connection failures on older devices. If you still get SSL errors, try updating Google Play Services in the Play Store and restart the app!\n\n - - other bug fixes + - Other bug fixes 3.3.1:\n diff --git a/Awful.apk/src/main/res/values/ic_launcher_background.xml b/Awful.apk/src/main/res/values/ic_launcher_background.xml new file mode 100644 index 000000000..9cb219783 --- /dev/null +++ b/Awful.apk/src/main/res/values/ic_launcher_background.xml @@ -0,0 +1,4 @@ + + + #006699 + \ No newline at end of file diff --git a/Awful.apk/src/main/res/values/preference_keys.xml b/Awful.apk/src/main/res/values/preference_keys.xml index eb7934edb..e8b3c0bda 100644 --- a/Awful.apk/src/main/res/values/preference_keys.xml +++ b/Awful.apk/src/main/res/values/preference_keys.xml @@ -35,6 +35,8 @@ op_highlight inline_youtube inline_tweets + inline_instagram + inline_twitch inline_vines inline_webm autostart_webm diff --git a/Awful.apk/src/main/res/values/strings.xml b/Awful.apk/src/main/res/values/strings.xml index 96684cbf4..b787e4106 100644 --- a/Awful.apk/src/main/res/values/strings.xml +++ b/Awful.apk/src/main/res/values/strings.xml @@ -23,6 +23,7 @@ Open a message or create a new one Forums updated + %d new announcement %d new announcements @@ -33,6 +34,10 @@ %d unread announcements + Getting announcements… + No current announcements + Couldn\'t get announcements! + Open @@ -173,6 +178,12 @@ Alphabetical + + + from URL + image + + List style List items (one per added line) Code @@ -224,6 +235,39 @@ Quoting user + + Add to Imgur and insert + Upload + + Tap to select an image + Select an image to upload + Name: %s + Size: %s + unknown + Can\'t get file details + + MUST start with http:// or https:// + This doesn\'t look like a url… + + Unknown upload availability + Resets at %1$s on %2$s + + No uploads left! + %d upload left + %d uploads left + + + Enter an image URL above + Select an image above + Ready to upload + Uploading… + Out of upload credits! + + Max upload size is 10MB\nYour image is %s + Couldn\'t read your image file + Unrecognized site response! + Upload error! + Embed elements in posts instead of just showing a link. These options may cause scroll lag or graphical glitches Tweets + Instagram Videos YouTube Vine + Twitch + Slow with several on a page Other WebM/GIFV/MP4 etc. Autoplay embedded videos diff --git a/Awful.apk/src/main/res/values/styles.xml b/Awful.apk/src/main/res/values/styles.xml index 906fa4aa5..6d68a0a9a 100644 --- a/Awful.apk/src/main/res/values/styles.xml +++ b/Awful.apk/src/main/res/values/styles.xml @@ -65,36 +65,36 @@ diff --git a/Awful.apk/src/main/res/xml/post_embedding_settings.xml b/Awful.apk/src/main/res/xml/post_embedding_settings.xml index 96527eb32..e963d16d8 100644 --- a/Awful.apk/src/main/res/xml/post_embedding_settings.xml +++ b/Awful.apk/src/main/res/xml/post_embedding_settings.xml @@ -12,6 +12,12 @@ android:title="@string/inline_tweets" /> + + @@ -27,6 +33,13 @@ android:title="@string/inline_vines" /> + +