From a528b8e0b2cd1ac202cca59c8e3409db9e81a2ca Mon Sep 17 00:00:00 2001 From: Beau Collins Date: Mon, 30 Jun 2014 12:41:40 -0400 Subject: [PATCH 01/31] Initial Project Structure Produces an *.aar file to use your Android app. As we embark on this journey to decompose WordPress-Android into pure platonic components, let us commemorate this first library project with words by Douglas Adams: > There is a theory which states that if ever anyone discovers exactly what the Universe is for and why it is here, it will instantly disappear and be replaced by something even more bizarre and inexplicable. There is another theory which states that this has already happened. --- .gitignore | 2 + WordPressUtils/build.gradle | 28 +++ WordPressUtils/src/main/AndroidManifest.xml | 5 + .../main/org/wordpress/android/util/README.md | 0 build.gradle | 3 + gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 51348 bytes gradle/wrapper/gradle-wrapper.properties | 6 + gradlew | 164 ++++++++++++++++++ gradlew.bat | 90 ++++++++++ settings.gradle | 1 + 10 files changed, 299 insertions(+) create mode 100644 .gitignore create mode 100644 WordPressUtils/build.gradle create mode 100644 WordPressUtils/src/main/AndroidManifest.xml create mode 100644 WordPressUtils/src/main/org/wordpress/android/util/README.md create mode 100644 build.gradle create mode 100644 gradle/wrapper/gradle-wrapper.jar create mode 100644 gradle/wrapper/gradle-wrapper.properties create mode 100755 gradlew create mode 100644 gradlew.bat create mode 100644 settings.gradle diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000000..b40b05459907 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.gradle/ +build/* \ No newline at end of file diff --git a/WordPressUtils/build.gradle b/WordPressUtils/build.gradle new file mode 100644 index 000000000000..5c769d9cfc40 --- /dev/null +++ b/WordPressUtils/build.gradle @@ -0,0 +1,28 @@ + +buildscript { + repositories { + mavenCentral() + } + dependencies { classpath 'com.android.tools.build:gradle:0.11.+' } +} + +apply plugin: 'android-library' + +repositories { + mavenCentral() +} + +android { + + compileSdkVersion 19 + buildToolsVersion "19.1.0" + + defaultConfig { + applicationId "org.wordpress.android.utils" + versionName "1.0.0" + versionCode 1 + minSdkVersion 14 + targetSdkVersion 19 + } + +} diff --git a/WordPressUtils/src/main/AndroidManifest.xml b/WordPressUtils/src/main/AndroidManifest.xml new file mode 100644 index 000000000000..f7450eba108c --- /dev/null +++ b/WordPressUtils/src/main/AndroidManifest.xml @@ -0,0 +1,5 @@ + + + + diff --git a/WordPressUtils/src/main/org/wordpress/android/util/README.md b/WordPressUtils/src/main/org/wordpress/android/util/README.md new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/build.gradle b/build.gradle new file mode 100644 index 000000000000..47a0f376a65f --- /dev/null +++ b/build.gradle @@ -0,0 +1,3 @@ +task wrapper(type: Wrapper) { + gradleVersion = '1.11' +} diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..0087cd3b18659b5577cf6ad3ef61f8eb9416ebba GIT binary patch literal 51348 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

CflOl1i4(-2^1KXo)&I5QlgjRKFQgM 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 z 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 literal 0 HcmV?d00001 diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 000000000000..67a8a664b1e5 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,6 @@ +#Mon Jun 30 12:35:54 EDT 2014 +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-1.11-bin.zip diff --git a/gradlew b/gradlew new file mode 100755 index 000000000000..91a7e269e19d --- /dev/null +++ b/gradlew @@ -0,0 +1,164 @@ +#!/usr/bin/env bash + +############################################################################## +## +## Gradle start up script for UN*X +## +############################################################################## + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS="" + +APP_NAME="Gradle" +APP_BASE_NAME=`basename "$0"` + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD="maximum" + +warn ( ) { + echo "$*" +} + +die ( ) { + echo + echo "$*" + echo + exit 1 +} + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +case "`uname`" in + CYGWIN* ) + cygwin=true + ;; + Darwin* ) + darwin=true + ;; + MINGW* ) + msys=true + ;; +esac + +# For Cygwin, ensure paths are in UNIX format before anything is touched. +if $cygwin ; then + [ -n "$JAVA_HOME" ] && JAVA_HOME=`cygpath --unix "$JAVA_HOME"` +fi + +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +PRG="$0" +# Need this for relative symlinks. +while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi +done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" >&- +APP_HOME="`pwd -P`" +cd "$SAVED" >&- + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD="java" + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if [ "$cygwin" = "false" -a "$darwin" = "false" ] ; then + MAX_FD_LIMIT=`ulimit -H -n` + if [ $? -eq 0 ] ; then + if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then + MAX_FD="$MAX_FD_LIMIT" + fi + ulimit -n $MAX_FD + if [ $? -ne 0 ] ; then + warn "Could not set maximum file descriptor limit: $MAX_FD" + fi + else + warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" + fi +fi + +# For Darwin, add options to specify how the application appears in the dock +if $darwin; then + GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" +fi + +# For Cygwin, switch paths to Windows format before running java +if $cygwin ; then + APP_HOME=`cygpath --path --mixed "$APP_HOME"` + CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` + + # We build the pattern for arguments to be converted via cygpath + ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` + SEP="" + for dir in $ROOTDIRSRAW ; do + ROOTDIRS="$ROOTDIRS$SEP$dir" + SEP="|" + done + OURCYGPATTERN="(^($ROOTDIRS))" + # Add a user-defined pattern to the cygpath arguments + if [ "$GRADLE_CYGPATTERN" != "" ] ; then + OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" + fi + # Now convert the arguments - kludge to limit ourselves to /bin/sh + i=0 + for arg in "$@" ; do + CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` + CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option + + if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition + eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` + else + eval `echo args$i`="\"$arg\"" + fi + i=$((i+1)) + done + case $i in + (0) set -- ;; + (1) set -- "$args0" ;; + (2) set -- "$args0" "$args1" ;; + (3) set -- "$args0" "$args1" "$args2" ;; + (4) set -- "$args0" "$args1" "$args2" "$args3" ;; + (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; + (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; + (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; + (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; + (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; + esac +fi + +# Split up the JVM_OPTS And GRADLE_OPTS values into an array, following the shell quoting and substitution rules +function splitJvmOpts() { + JVM_OPTS=("$@") +} +eval splitJvmOpts $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS +JVM_OPTS[${#JVM_OPTS[*]}]="-Dorg.gradle.appname=$APP_BASE_NAME" + +exec "$JAVACMD" "${JVM_OPTS[@]}" -classpath "$CLASSPATH" org.gradle.wrapper.GradleWrapperMain "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 000000000000..aec99730b4e8 --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,90 @@ +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS= + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto init + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto init + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:init +@rem Get command-line arguments, handling Windowz variants + +if not "%OS%" == "Windows_NT" goto win9xME_args +if "%@eval[2+2]" == "4" goto 4NT_args + +:win9xME_args +@rem Slurp the command line arguments. +set CMD_LINE_ARGS= +set _SKIP=2 + +:win9xME_args_slurp +if "x%~1" == "x" goto execute + +set CMD_LINE_ARGS=%* +goto execute + +:4NT_args +@rem Get arguments from the 4NT Shell from JP Software +set CMD_LINE_ARGS=%$ + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/settings.gradle b/settings.gradle new file mode 100644 index 000000000000..3519745edd00 --- /dev/null +++ b/settings.gradle @@ -0,0 +1 @@ +include ':WordPressUtils' \ No newline at end of file From 826c265d8caf729cb00ee9379ac72266fb482fdf Mon Sep 17 00:00:00 2001 From: Beau Collins Date: Mon, 30 Jun 2014 13:24:41 -0400 Subject: [PATCH 02/31] Move java source to correct directory --- .../src/main/java/org/wordpress/android/util/README.md | 1 + WordPressUtils/src/main/org/wordpress/android/util/README.md | 0 2 files changed, 1 insertion(+) create mode 100644 WordPressUtils/src/main/java/org/wordpress/android/util/README.md delete mode 100644 WordPressUtils/src/main/org/wordpress/android/util/README.md diff --git a/WordPressUtils/src/main/java/org/wordpress/android/util/README.md b/WordPressUtils/src/main/java/org/wordpress/android/util/README.md new file mode 100644 index 000000000000..62a759585e63 --- /dev/null +++ b/WordPressUtils/src/main/java/org/wordpress/android/util/README.md @@ -0,0 +1 @@ +# org.wordpress.android.util \ No newline at end of file diff --git a/WordPressUtils/src/main/org/wordpress/android/util/README.md b/WordPressUtils/src/main/org/wordpress/android/util/README.md deleted file mode 100644 index e69de29bb2d1..000000000000 From 0228ac499a1b6772c2d5e117b173479b883c34c9 Mon Sep 17 00:00:00 2001 From: Beau Collins Date: Mon, 30 Jun 2014 17:14:05 -0400 Subject: [PATCH 03/31] Fix package id name --- WordPressUtils/build.gradle | 2 +- WordPressUtils/src/main/AndroidManifest.xml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/WordPressUtils/build.gradle b/WordPressUtils/build.gradle index 5c769d9cfc40..cf5c41bbe577 100644 --- a/WordPressUtils/build.gradle +++ b/WordPressUtils/build.gradle @@ -18,7 +18,7 @@ android { buildToolsVersion "19.1.0" defaultConfig { - applicationId "org.wordpress.android.utils" + applicationId "org.wordpress.android.util" versionName "1.0.0" versionCode 1 minSdkVersion 14 diff --git a/WordPressUtils/src/main/AndroidManifest.xml b/WordPressUtils/src/main/AndroidManifest.xml index f7450eba108c..4f3bd125a3c5 100644 --- a/WordPressUtils/src/main/AndroidManifest.xml +++ b/WordPressUtils/src/main/AndroidManifest.xml @@ -1,5 +1,5 @@ + package="org.wordpress.android.util"> From 42633436e4d7c388942f5fc7e9882e8bf66ab9b6 Mon Sep 17 00:00:00 2001 From: Dan Roundhill Date: Tue, 1 Jul 2014 12:33:35 -0400 Subject: [PATCH 04/31] Initial commit of utils classes. --- WordPressUtils/WordPressUtils.iml | 69 +++ WordPressUtils/build.gradle | 8 +- .../org/wordpress/android/util/AlertUtil.java | 101 ++++ .../org/wordpress/android/util/AppLog.java | 214 ++++++++ .../org/wordpress/android/util/BlogUtils.java | 25 + .../wordpress/android/util/DeviceUtils.java | 94 ++++ .../wordpress/android/util/DisplayUtils.java | 93 ++++ .../wordpress/android/util/EditTextUtils.java | 77 +++ .../org/wordpress/android/util/Emoticons.java | 106 ++++ .../wordpress/android/util/FormatUtils.java | 35 ++ .../wordpress/android/util/GeocoderUtils.java | 116 +++++ .../wordpress/android/util/GravatarUtils.java | 22 + .../org/wordpress/android/util/HtmlUtils.java | 128 +++++ .../wordpress/android/util/ImageUtils.java | 493 ++++++++++++++++++ .../org/wordpress/android/util/JSONUtil.java | 236 +++++++++ .../util/ListScrollPositionManager.java | 36 ++ .../android/util/LocationHelper.java | 132 +++++ .../org/wordpress/android/util/MapUtils.java | 79 +++ .../wordpress/android/util/PhotonUtils.java | 96 ++++ .../android/util/ProfilingUtils.java | 91 ++++ .../org/wordpress/android/util/SqlUtils.java | 121 +++++ .../wordpress/android/util/StringUtils.java | 278 ++++++++++ .../android/util/SystemServiceFactory.java | 17 + .../util/SystemServiceFactoryAbstract.java | 7 + .../util/SystemServiceFactoryDefault.java | 9 + .../org/wordpress/android/util/UrlUtils.java | 165 ++++++ .../org/wordpress/android/util/UserEmail.java | 30 ++ .../org/wordpress/android/util/Version.java | 47 ++ .../android/util/WPHtmlTagHandler.java | 59 +++ .../wordpress/android/util/WPQuoteSpan.java | 44 ++ .../android/util/WPWebChromeClient.java | 29 ++ 31 files changed, 3055 insertions(+), 2 deletions(-) create mode 100644 WordPressUtils/WordPressUtils.iml create mode 100644 WordPressUtils/src/main/java/org/wordpress/android/util/AlertUtil.java create mode 100644 WordPressUtils/src/main/java/org/wordpress/android/util/AppLog.java create mode 100644 WordPressUtils/src/main/java/org/wordpress/android/util/BlogUtils.java create mode 100644 WordPressUtils/src/main/java/org/wordpress/android/util/DeviceUtils.java create mode 100644 WordPressUtils/src/main/java/org/wordpress/android/util/DisplayUtils.java create mode 100644 WordPressUtils/src/main/java/org/wordpress/android/util/EditTextUtils.java create mode 100644 WordPressUtils/src/main/java/org/wordpress/android/util/Emoticons.java create mode 100644 WordPressUtils/src/main/java/org/wordpress/android/util/FormatUtils.java create mode 100644 WordPressUtils/src/main/java/org/wordpress/android/util/GeocoderUtils.java create mode 100644 WordPressUtils/src/main/java/org/wordpress/android/util/GravatarUtils.java create mode 100644 WordPressUtils/src/main/java/org/wordpress/android/util/HtmlUtils.java create mode 100644 WordPressUtils/src/main/java/org/wordpress/android/util/ImageUtils.java create mode 100644 WordPressUtils/src/main/java/org/wordpress/android/util/JSONUtil.java create mode 100644 WordPressUtils/src/main/java/org/wordpress/android/util/ListScrollPositionManager.java create mode 100644 WordPressUtils/src/main/java/org/wordpress/android/util/LocationHelper.java create mode 100644 WordPressUtils/src/main/java/org/wordpress/android/util/MapUtils.java create mode 100644 WordPressUtils/src/main/java/org/wordpress/android/util/PhotonUtils.java create mode 100644 WordPressUtils/src/main/java/org/wordpress/android/util/ProfilingUtils.java create mode 100644 WordPressUtils/src/main/java/org/wordpress/android/util/SqlUtils.java create mode 100644 WordPressUtils/src/main/java/org/wordpress/android/util/StringUtils.java create mode 100644 WordPressUtils/src/main/java/org/wordpress/android/util/SystemServiceFactory.java create mode 100644 WordPressUtils/src/main/java/org/wordpress/android/util/SystemServiceFactoryAbstract.java create mode 100644 WordPressUtils/src/main/java/org/wordpress/android/util/SystemServiceFactoryDefault.java create mode 100644 WordPressUtils/src/main/java/org/wordpress/android/util/UrlUtils.java create mode 100644 WordPressUtils/src/main/java/org/wordpress/android/util/UserEmail.java create mode 100644 WordPressUtils/src/main/java/org/wordpress/android/util/Version.java create mode 100644 WordPressUtils/src/main/java/org/wordpress/android/util/WPHtmlTagHandler.java create mode 100644 WordPressUtils/src/main/java/org/wordpress/android/util/WPQuoteSpan.java create mode 100644 WordPressUtils/src/main/java/org/wordpress/android/util/WPWebChromeClient.java diff --git a/WordPressUtils/WordPressUtils.iml b/WordPressUtils/WordPressUtils.iml new file mode 100644 index 000000000000..86bc5a99c075 --- /dev/null +++ b/WordPressUtils/WordPressUtils.iml @@ -0,0 +1,69 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/WordPressUtils/build.gradle b/WordPressUtils/build.gradle index cf5c41bbe577..88142d1a06a6 100644 --- a/WordPressUtils/build.gradle +++ b/WordPressUtils/build.gradle @@ -3,15 +3,19 @@ buildscript { repositories { mavenCentral() } - dependencies { classpath 'com.android.tools.build:gradle:0.11.+' } + dependencies { classpath 'com.android.tools.build:gradle:0.12.+' } } -apply plugin: 'android-library' +apply plugin: 'com.android.library' repositories { mavenCentral() } +dependencies { + compile 'commons-lang:commons-lang:2.6' +} + android { compileSdkVersion 19 diff --git a/WordPressUtils/src/main/java/org/wordpress/android/util/AlertUtil.java b/WordPressUtils/src/main/java/org/wordpress/android/util/AlertUtil.java new file mode 100644 index 000000000000..76800de4cd6d --- /dev/null +++ b/WordPressUtils/src/main/java/org/wordpress/android/util/AlertUtil.java @@ -0,0 +1,101 @@ +/* + * Copyright (C) 2011 wordpress.org + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.wordpress.android.util; + +import android.app.AlertDialog; +import android.app.Dialog; +import android.content.Context; +import android.content.DialogInterface; + +public class AlertUtil { + /** + * Show Alert Dialog + * @param context + * @param titleId + * @param messageId + */ + public static void showAlert(Context context, int titleId, int messageId) { + Dialog dlg = new AlertDialog.Builder(context) + .setTitle(titleId) + .setPositiveButton(android.R.string.ok, null) + .setMessage(messageId) + .create(); + + dlg.show(); + } + + /** + * Show Alert Dialog + * @param context + * @param titleId + * @param messageId + */ + public static void showAlert(Context context, int titleId, String message) { + Dialog dlg = new AlertDialog.Builder(context) + .setTitle(titleId) + .setPositiveButton(android.R.string.ok, null) + .setMessage(message) + .create(); + + dlg.show(); + } + + /** + * Show Alert Dialog + * @param context + * @param titleId + * @param messageId + * @param positiveButtontxt + * @param positiveListener + * @param negativeButtontxt + * @param negativeListener + */ + public static void showAlert(Context context, int titleId, int messageId, + CharSequence positiveButtontxt, DialogInterface.OnClickListener positiveListener, + CharSequence negativeButtontxt, DialogInterface.OnClickListener negativeListener) { + Dialog dlg = new AlertDialog.Builder(context) + .setTitle(titleId) + .setPositiveButton(positiveButtontxt, positiveListener) + .setNegativeButton(negativeButtontxt, negativeListener) + .setMessage(messageId) + .setCancelable(false) + .create(); + + dlg.show(); + } + + /** + * Show Alert Dialog + * @param context + * @param titleId + * @param messageId + * @param positiveButtontxt + * @param positiveListener + */ + public static void showAlert(Context context, int titleId, String message, + CharSequence positiveButtontxt, DialogInterface.OnClickListener positiveListener) { + Dialog dlg = new AlertDialog.Builder(context) + .setTitle(titleId) + .setPositiveButton(positiveButtontxt, positiveListener) + .setMessage(message) + .setCancelable(false) + .create(); + + dlg.show(); + } +} + diff --git a/WordPressUtils/src/main/java/org/wordpress/android/util/AppLog.java b/WordPressUtils/src/main/java/org/wordpress/android/util/AppLog.java new file mode 100644 index 000000000000..f2fff1b2ef4a --- /dev/null +++ b/WordPressUtils/src/main/java/org/wordpress/android/util/AppLog.java @@ -0,0 +1,214 @@ +package org.wordpress.android.util; + +import android.content.Context; +import android.text.TextUtils; +import android.util.Log; + +import java.io.PrintWriter; +import java.io.StringWriter; +import java.util.ArrayList; +import java.util.Iterator; +import java.util.NoSuchElementException; + +/** + * simple wrapper for Android log calls, enables recording & displaying log + */ +public class AppLog { + // T for Tag + public enum T {READER, EDITOR, MEDIA, NUX, API, STATS, UTILS, NOTIFS, DB, POSTS, COMMENTS, THEMES, TESTS, PROFILING, SIMPERIUM} + public static final String TAG = "WordPress"; + + private static boolean mEnableRecording = false; + + private AppLog() { + throw new AssertionError(); + } + + /* + * defaults to false, pass true to capture log so it can be displayed by AppLogViewerActivity + */ + public static void enableRecording(boolean enable) { + mEnableRecording = enable; + } + + public static void v(T tag, String message) { + Log.v(TAG + "-" + tag.toString(), message); + addEntry(tag, LogLevel.v, message); + } + + public static void d(T tag, String message) { + Log.d(TAG + "-" + tag.toString(), message); + addEntry(tag, LogLevel.d, message); + } + + public static void i(T tag, String message) { + Log.i(TAG + "-" + tag.toString(), message); + addEntry(tag, LogLevel.i, message); + } + + public static void w(T tag, String message) { + Log.w(TAG + "-" + tag.toString(), message); + addEntry(tag, LogLevel.w, message); + } + + public static void e(T tag, String message) { + Log.e(TAG + "-" + tag.toString(), message); + addEntry(tag, LogLevel.e, message); + } + + public static void e(T tag, String message, Throwable tr) { + Log.e(TAG + "-" + tag.toString(), message, tr); + addEntry(tag, LogLevel.e, message + " - exception: " + tr.getMessage()); + addEntry(tag, LogLevel.e, "StackTrace: " + getHTMLStringStackTrace(tr)); + } + + public static void e(T tag, Throwable tr) { + Log.e(TAG + "-" + tag.toString(), tr.getMessage(), tr); + addEntry(tag, LogLevel.e, tr.getMessage()); + addEntry(tag, LogLevel.e, "StackTrace: " + getHTMLStringStackTrace(tr)); + } + + public static void e(T tag, String volleyErrorMsg, int statusCode) { + if (TextUtils.isEmpty(volleyErrorMsg)) { + return; + } + String logText; + if (statusCode == -1) { + logText = volleyErrorMsg; + } else { + logText = volleyErrorMsg + ", status " + statusCode; + } + Log.e(TAG + "-" + tag.toString(), logText); + addEntry(tag, LogLevel.w, logText); + } + + // -------------------------------------------------------------------------------------------------------- + + private static final int MAX_ENTRIES = 99; + + private enum LogLevel { + v, d, i, w, e; + private String toHtmlColor() { + switch(this) { + case v: + return "grey"; + case i: + return "black"; + case w: + return "purple"; + case e: + return "red"; + case d: + default: + return "teal"; + } + } + } + + private static class LogEntry { + LogLevel logLevel; + String logText; + T logTag; + + private String toHtml() { + StringBuilder sb = new StringBuilder() + .append("") + .append("[") + .append(logTag.name()) + .append("] ") + .append(logLevel.name()) + .append(": ") + .append(logText) + .append(""); + return sb.toString(); + } + } + + private static class LogEntryList extends ArrayList { + private synchronized boolean addEntry(LogEntry entry) { + if (size() >= MAX_ENTRIES) + removeFirstEntry(); + return add(entry); + } + private void removeFirstEntry() { + Iterator it = iterator(); + if (!it.hasNext()) + return; + try { + remove(it.next()); + } catch (NoSuchElementException e) { + // ignore + } + } + } + + private static LogEntryList mLogEntries = new LogEntryList(); + + private static void addEntry(T tag, LogLevel level, String text) { + // skip if recording is disabled (default) + if (!mEnableRecording) + return; + LogEntry entry = new LogEntry(); + entry.logLevel = level; + entry.logText = text; + entry.logTag = tag; + mLogEntries.addEntry(entry); + } + + private static String getStringStackTrace(Throwable throwable) { + StringWriter errors = new StringWriter(); + throwable.printStackTrace(new PrintWriter(errors)); + return errors.toString(); + } + + private static String getHTMLStringStackTrace(Throwable throwable) { + return getStringStackTrace(throwable).replace("\n", "
"); + } + + /* + * returns entire log as html for display (see AppLogViewerActivity) + */ + public static String toHtml(Context context) { + StringBuilder sb = new StringBuilder(); + + // add version & device info + sb.append("WordPress Android version: " + ProfilingUtils.getVersionName(context)).append("
") + .append("Android device name: " + DeviceUtils.getInstance().getDeviceName(context)).append("
"); + + Iterator it = mLogEntries.iterator(); + int lineNum = 1; + while (it.hasNext()) { + sb.append("") + .append(String.format("%02d", lineNum)) + .append(" ") + .append(it.next().toHtml()) + .append("
"); + lineNum++; + } + return sb.toString(); + } + + + /* + * returns entire log as plain text + */ + public static String toPlainText(Context context) { + StringBuilder sb = new StringBuilder(); + + // add version & device info + sb.append("WordPress Android version: " + ProfilingUtils.getVersionName(context)).append("\n") + .append("Android device name: " + DeviceUtils.getInstance().getDeviceName(context)).append("\n\n"); + + Iterator it = mLogEntries.iterator(); + int lineNum = 1; + while (it.hasNext()) { + sb.append(String.format("%02d - ", lineNum)) + .append(it.next().logText) + .append("\n"); + lineNum++; + } + return sb.toString(); + } +} diff --git a/WordPressUtils/src/main/java/org/wordpress/android/util/BlogUtils.java b/WordPressUtils/src/main/java/org/wordpress/android/util/BlogUtils.java new file mode 100644 index 000000000000..166085a4f066 --- /dev/null +++ b/WordPressUtils/src/main/java/org/wordpress/android/util/BlogUtils.java @@ -0,0 +1,25 @@ +package org.wordpress.android.util; + +import java.util.Comparator; +import java.util.Map; + +public class BlogUtils { + public static Comparator BlogNameComparator = new Comparator() { + public int compare(Object blog1, Object blog2) { + Map blogMap1 = (Map) blog1; + Map blogMap2 = (Map) blog2; + + String blogName1 = MapUtils.getMapStr(blogMap1, "blogName"); + if (blogName1.length() == 0) { + blogName1 = MapUtils.getMapStr(blogMap1, "url"); + } + + String blogName2 = MapUtils.getMapStr(blogMap2, "blogName"); + if (blogName2.length() == 0) { + blogName2 = MapUtils.getMapStr(blogMap2, "url"); + } + + return blogName1.compareToIgnoreCase(blogName2); + } + }; +} \ No newline at end of file diff --git a/WordPressUtils/src/main/java/org/wordpress/android/util/DeviceUtils.java b/WordPressUtils/src/main/java/org/wordpress/android/util/DeviceUtils.java new file mode 100644 index 000000000000..639d5479c301 --- /dev/null +++ b/WordPressUtils/src/main/java/org/wordpress/android/util/DeviceUtils.java @@ -0,0 +1,94 @@ +package org.wordpress.android.util; + +import android.content.Context; +import android.content.pm.PackageManager; +import android.os.Build; + +import org.wordpress.android.util.AppLog.T; + +import java.io.IOException; +import java.io.InputStream; +import java.util.Properties; + +public class DeviceUtils { + private static DeviceUtils instance; + private boolean isKindleFire = false; + + public boolean isKindleFire() { + return isKindleFire; + } + + public static DeviceUtils getInstance() { + if (instance == null) { + instance = new DeviceUtils(); + } + return instance; + } + + private DeviceUtils() { + isKindleFire = android.os.Build.MODEL.equalsIgnoreCase("kindle fire") ? true: false; + } + + /** + * Checks camera availability recursively based on API level. + * + * TODO: change "android.hardware.camera.front" and "android.hardware.camera.any" to + * {@link PackageManager#FEATURE_CAMERA_FRONT} and {@link PackageManager#FEATURE_CAMERA_ANY}, + * respectively, once they become accessible or minSdk version is incremented. + * + * @param context The context. + * @return Whether camera is available. + */ + public boolean hasCamera(Context context) { + final PackageManager pm = context.getPackageManager(); + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.JELLY_BEAN_MR1) { + return pm.hasSystemFeature(PackageManager.FEATURE_CAMERA) + || pm.hasSystemFeature("android.hardware.camera.front"); + } + + return pm.hasSystemFeature("android.hardware.camera.any"); + } + + public String getDeviceName(Context context) { + String manufacturer = Build.MANUFACTURER; + String undecodedModel = Build.MODEL; + String model = null; + + try { + Properties prop = new Properties(); + InputStream fileStream; + // Read the device name from a precomplied list: + // see http://making.meetup.com/post/29648976176/human-readble-android-device-names + fileStream = context.getAssets().open("android_models.properties"); + prop.load(fileStream); + fileStream.close(); + String decodedModel = prop.getProperty(undecodedModel.replaceAll(" ", "_")); + if (decodedModel != null && !decodedModel.trim().equals("")) { + model = decodedModel; + } + } catch (IOException e) { + AppLog.e(T.UTILS, e.getMessage()); + } + + if (model == null) { //Device model not found in the list + if (undecodedModel.startsWith(manufacturer)) { + model = capitalize(undecodedModel); + } else { + model = capitalize(manufacturer) + " " + undecodedModel; + } + } + return model; + } + + private String capitalize(String s) { + if (s == null || s.length() == 0) { + return ""; + } + char first = s.charAt(0); + if (Character.isUpperCase(first)) { + return s; + } else { + return Character.toUpperCase(first) + s.substring(1); + } + } +} diff --git a/WordPressUtils/src/main/java/org/wordpress/android/util/DisplayUtils.java b/WordPressUtils/src/main/java/org/wordpress/android/util/DisplayUtils.java new file mode 100644 index 000000000000..f64527e9a622 --- /dev/null +++ b/WordPressUtils/src/main/java/org/wordpress/android/util/DisplayUtils.java @@ -0,0 +1,93 @@ +package org.wordpress.android.util; + +import android.content.Context; +import android.content.res.Configuration; +import android.graphics.Point; +import android.util.DisplayMetrics; +import android.util.TypedValue; +import android.view.Display; +import android.view.Window; +import android.view.WindowManager; + +public class DisplayUtils { + private DisplayUtils() { + throw new AssertionError(); + } + + public static boolean isLandscape(Context context) { + if (context == null) + return false; + return context.getResources().getConfiguration().orientation == Configuration.ORIENTATION_LANDSCAPE; + } + + public static boolean isLandscapeTablet(Context context) { + return isLandscape(context) && isTablet(context); + } + + public static Point getDisplayPixelSize(Context context) { + WindowManager wm = (WindowManager) context.getSystemService(Context.WINDOW_SERVICE); + Display display = wm.getDefaultDisplay(); + Point size = new Point(); + display.getSize(size); + return size; + } + + public static int getDisplayPixelWidth(Context context) { + Point size = getDisplayPixelSize(context); + return (size.x); + } + + public static int getDisplayPixelHeight(Context context) { + Point size = getDisplayPixelSize(context); + return (size.y); + } + + public static int dpToPx(Context context, int dp) { + float px = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, dp, context.getResources().getDisplayMetrics()); + return (int) px; + } + + public static int pxToDp(Context context, int px) { + DisplayMetrics displayMetrics = context.getResources().getDisplayMetrics(); + return (int) ((px/displayMetrics.density)+0.5); + } + + public static boolean isTablet(Context context) { + // http://stackoverflow.com/a/8427523/1673548 + if (context == null) + return false; + return (context.getResources().getConfiguration().screenLayout & Configuration.SCREENLAYOUT_SIZE_MASK) >= Configuration.SCREENLAYOUT_SIZE_LARGE; + } + + public static boolean isXLarge(Context context) { + if ((context.getResources().getConfiguration().screenLayout & Configuration.SCREENLAYOUT_SIZE_MASK) == Configuration.SCREENLAYOUT_SIZE_XLARGE) + return true; + return false; + } + + /** + * returns the height of the ActionBar if one is enabled - supports both the native ActionBar + * and ActionBarSherlock - http://stackoverflow.com/a/15476793/1673548 + */ + public static int getActionBarHeight(Context context) { + if (context == null) { + return 0; + } + TypedValue tv = new TypedValue(); + if (context.getTheme() != null + && context.getTheme().resolveAttribute(android.R.attr.actionBarSize, tv, true)) { + return TypedValue.complexToDimensionPixelSize(tv.data, context.getResources().getDisplayMetrics()); + } + + // if we get this far, it's because the device doesn't support an ActionBar, + // so return the standard ActionBar height (48dp) + return dpToPx(context, 48); + } + + /** + * detect when FEATURE_ACTION_BAR_OVERLAY has been set + */ + public static boolean hasActionBarOverlay(Window window) { + return window.hasFeature(Window.FEATURE_ACTION_BAR_OVERLAY); + } +} diff --git a/WordPressUtils/src/main/java/org/wordpress/android/util/EditTextUtils.java b/WordPressUtils/src/main/java/org/wordpress/android/util/EditTextUtils.java new file mode 100644 index 000000000000..64ee67e566a9 --- /dev/null +++ b/WordPressUtils/src/main/java/org/wordpress/android/util/EditTextUtils.java @@ -0,0 +1,77 @@ +package org.wordpress.android.util; + +import android.content.Context; +import android.text.TextUtils; +import android.view.inputmethod.InputMethodManager; +import android.widget.EditText; + +/** + * EditText utils + */ +public class EditTextUtils { + private EditTextUtils() { + throw new AssertionError(); + } + + /** + * returns text string from passed EditText + */ + public static String getText(EditText edit) { + if (edit.getText() == null) { + return ""; + } + return edit.getText().toString(); + } + + /** + * moves caret to end of text + */ + public static void moveToEnd(EditText edit) { + if (edit.getText() == null) { + return; + } + edit.setSelection(edit.getText().toString().length()); + } + + /** + * returns true if nothing has been entered into passed editor + */ + public static boolean isEmpty(EditText edit) { + return TextUtils.isEmpty(getText(edit)); + } + + /** + * hide the soft keyboard for the passed EditText + */ + public static void hideSoftInput(EditText edit) { + if (edit == null) { + return; + } + + InputMethodManager imm = getInputMethodManager(edit); + if (imm != null) { + imm.hideSoftInputFromWindow(edit.getWindowToken(), 0); + } + } + + /** + * show the soft keyboard for the passed EditText + */ + public static void showSoftInput(EditText edit) { + if (edit == null) { + return; + } + + edit.requestFocus(); + + InputMethodManager imm = getInputMethodManager(edit); + if (imm != null) { + imm.showSoftInput(edit, InputMethodManager.SHOW_IMPLICIT); + } + } + + private static InputMethodManager getInputMethodManager(EditText edit) { + Context context = edit.getContext(); + return (InputMethodManager) context.getSystemService(Context.INPUT_METHOD_SERVICE); + } +} diff --git a/WordPressUtils/src/main/java/org/wordpress/android/util/Emoticons.java b/WordPressUtils/src/main/java/org/wordpress/android/util/Emoticons.java new file mode 100644 index 000000000000..5a7566a967cf --- /dev/null +++ b/WordPressUtils/src/main/java/org/wordpress/android/util/Emoticons.java @@ -0,0 +1,106 @@ +package org.wordpress.android.util; + +import android.text.Html; +import android.text.SpannableStringBuilder; +import android.text.Spanned; +import android.text.style.ForegroundColorSpan; +import android.text.style.ImageSpan; +import android.util.SparseArray; + +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +import static android.os.Build.VERSION.SDK_INT; +import static android.os.Build.VERSION_CODES; + +public class Emoticons { + public static final int EMOTICON_COLOR = 0xFF21759B; + private static final boolean HAS_EMOJI = SDK_INT >= VERSION_CODES.JELLY_BEAN; + private static final Map wpSmilies; + public static final SparseArray wpSmiliesCodePointToText; + + static { + Map smilies = new HashMap(); + smilies.put("icon_mrgreen.gif", HAS_EMOJI ? "\uD83D\uDE00" : ":mrgreen:" ); + smilies.put("icon_neutral.gif", HAS_EMOJI ? "\uD83D\uDE14" : ":|" ); + smilies.put("icon_twisted.gif", HAS_EMOJI ? "\uD83D\uDE16" : ":twisted:" ); + smilies.put("icon_arrow.gif", HAS_EMOJI ? "\u27A1" : ":arrow:" ); + smilies.put("icon_eek.gif", HAS_EMOJI ? "\uD83D\uDE32" : "8-O" ); + smilies.put("icon_smile.gif", HAS_EMOJI ? "\uD83D\uDE0A" : ":)" ); + smilies.put("icon_confused.gif", HAS_EMOJI ? "\uD83D\uDE15" : ":?" ); + smilies.put("icon_cool.gif", HAS_EMOJI ? "\uD83D\uDE0A" : "8)" ); + smilies.put("icon_evil.gif", HAS_EMOJI ? "\uD83D\uDE21" : ":evil:" ); + smilies.put("icon_biggrin.gif", HAS_EMOJI ? "\uD83D\uDE03" : ":D" ); + smilies.put("icon_idea.gif", HAS_EMOJI ? "\uD83D\uDCA1" : ":idea:" ); + smilies.put("icon_redface.gif", HAS_EMOJI ? "\uD83D\uDE33" : ":oops:" ); + smilies.put("icon_razz.gif", HAS_EMOJI ? "\uD83D\uDE1D" : ":P" ); + smilies.put("icon_rolleyes.gif", HAS_EMOJI ? "\uD83D\uDE0F" : ":roll:" ); + smilies.put("icon_wink.gif", HAS_EMOJI ? "\uD83D\uDE09" : ";)" ); + smilies.put("icon_cry.gif", HAS_EMOJI ? "\uD83D\uDE22" : ":'(" ); + smilies.put("icon_surprised.gif", HAS_EMOJI ? "\uD83D\uDE32" : ":o" ); + smilies.put("icon_lol.gif", HAS_EMOJI ? "\uD83D\uDE03" : ":lol:" ); + smilies.put("icon_mad.gif", HAS_EMOJI ? "\uD83D\uDE21" : ":x" ); + smilies.put("icon_sad.gif", HAS_EMOJI ? "\uD83D\uDE1E" : ":(" ); + smilies.put("icon_exclaim.gif", HAS_EMOJI ? "\u2757" : ":!:" ); + smilies.put("icon_question.gif", HAS_EMOJI ? "\u2753" : ":?:" ); + + wpSmilies = Collections.unmodifiableMap(smilies); + + wpSmiliesCodePointToText = new SparseArray(20); + wpSmiliesCodePointToText.put(10145, ":arrow:"); + wpSmiliesCodePointToText.put(128161, ":idea:"); + wpSmiliesCodePointToText.put(128512, ":mrgreen:"); + wpSmiliesCodePointToText.put(128515, ":D"); + wpSmiliesCodePointToText.put(128522, ":)"); + wpSmiliesCodePointToText.put(128521, ";)"); + wpSmiliesCodePointToText.put(128532, ":|"); + wpSmiliesCodePointToText.put(128533, ":?"); + wpSmiliesCodePointToText.put(128534, ":twisted:"); + wpSmiliesCodePointToText.put(128542, ":("); + wpSmiliesCodePointToText.put(128545, ":evil:"); + wpSmiliesCodePointToText.put(128546, ":'("); + wpSmiliesCodePointToText.put(128562, ":o"); + wpSmiliesCodePointToText.put(128563, ":oops:"); + wpSmiliesCodePointToText.put(128527, ":roll:"); + wpSmiliesCodePointToText.put(10071, ":!:"); + wpSmiliesCodePointToText.put(10067, ":?:"); + } + + public static String lookupImageSmiley(String url){ + return lookupImageSmiley(url, ""); + } + + public static String lookupImageSmiley(String url, String ifNone){ + String file = url.substring(url.lastIndexOf("/") + 1); + if (wpSmilies.containsKey(file)) { + return wpSmilies.get(file); + } + return ifNone; + } + + public static Spanned replaceEmoticonsWithEmoji(SpannableStringBuilder html){ + ImageSpan imgs[] = html.getSpans(0, html.length(), ImageSpan.class); + for (ImageSpan img : imgs) { + String emoticon = Emoticons.lookupImageSmiley(img.getSource()); + if (!emoticon.equals("")) { + int start = html.getSpanStart(img); + html.replace(start, html.getSpanEnd(img), emoticon); + html.setSpan(new ForegroundColorSpan(EMOTICON_COLOR), start, + start + emoticon.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + html.removeSpan(img); + } + } + return html; + } + + public static String replaceEmoticonsWithEmoji(final String text) { + if (text != null && text.contains("icon_")) { + final SpannableStringBuilder html = (SpannableStringBuilder)replaceEmoticonsWithEmoji((SpannableStringBuilder) Html.fromHtml(text)); + // Html.toHtml() is used here rather than toString() since the latter strips html + return Html.toHtml(html); + } else { + return text; + } + } +} \ No newline at end of file diff --git a/WordPressUtils/src/main/java/org/wordpress/android/util/FormatUtils.java b/WordPressUtils/src/main/java/org/wordpress/android/util/FormatUtils.java new file mode 100644 index 000000000000..28282ed5fadf --- /dev/null +++ b/WordPressUtils/src/main/java/org/wordpress/android/util/FormatUtils.java @@ -0,0 +1,35 @@ +package org.wordpress.android.util; + +import java.text.DecimalFormat; +import java.text.NumberFormat; + +public class FormatUtils { + /* + * NumberFormat isn't synchronized, so a separate instance must be created for each thread + * http://developer.android.com/reference/java/text/NumberFormat.html + */ + private static final ThreadLocal IntegerInstance = new ThreadLocal() { + @Override + protected NumberFormat initialValue() { + return NumberFormat.getIntegerInstance(); + } + }; + + private static final ThreadLocal DecimalInstance = new ThreadLocal() { + @Override + protected DecimalFormat initialValue() { + return (DecimalFormat) DecimalFormat.getInstance(); + } + }; + + /* + * returns the passed integer formatted with thousands-separators based on the current locale + */ + public static final String formatInt(int value) { + return IntegerInstance.get().format(value).toString(); + } + + public static final String formatDecimal(int value) { + return DecimalInstance.get().format(value).toString(); + } +} diff --git a/WordPressUtils/src/main/java/org/wordpress/android/util/GeocoderUtils.java b/WordPressUtils/src/main/java/org/wordpress/android/util/GeocoderUtils.java new file mode 100644 index 000000000000..e861a88b8a88 --- /dev/null +++ b/WordPressUtils/src/main/java/org/wordpress/android/util/GeocoderUtils.java @@ -0,0 +1,116 @@ +package org.wordpress.android.util; + +import android.content.Context; +import android.location.Address; +import android.location.Geocoder; + +import java.io.IOException; +import java.util.List; +import java.util.Locale; + +public final class GeocoderUtils { + private GeocoderUtils() { + throw new AssertionError(); + } + + public static Geocoder getGeocoder(Context context) { + // first make sure a Geocoder service exists on this device (requires API 9) + if (!Geocoder.isPresent()) { + return null; + } + + Geocoder gcd; + + try { + gcd = new Geocoder(context, Locale.getDefault()); + } catch (NullPointerException cannotIstantiateEx) { + AppLog.e(AppLog.T.UTILS, "Cannot instantiate Geocoder", cannotIstantiateEx); + return null; + } + + return gcd; + } + + public static Address getAddressFromCoords(Context context, double latitude, double longitude) { + Address address = null; + List
addresses = null; + + Geocoder gcd = getGeocoder(context); + + if (gcd == null) { + return null; + } + + try { + addresses = gcd.getFromLocation(latitude, longitude, 1); + } catch (IOException e) { + // may get "Unable to parse response from server" IOException here if Geocoder + // service is hit too frequently + AppLog.e(AppLog.T.UTILS, + "Unable to parse response from server. Is Geocoder service hitting the server too frequently?", + e + ); + } + + // addresses may be null or empty if network isn't connected + if (addresses != null && addresses.size() > 0) { + address = addresses.get(0); + } + + return address; + } + + public static Address getAddressFromLocationName(Context context, String locationName) { + int maxResults = 1; + Address address = null; + List
addresses = null; + + Geocoder gcd = getGeocoder(context); + + if (gcd == null) { + return null; + } + + try { + addresses = gcd.getFromLocationName(locationName, maxResults); + } catch (IOException e) { + AppLog.e(AppLog.T.UTILS, "Failed to get coordinates from location", e); + } + + // addresses may be null or empty if network isn't connected + if (addresses != null && addresses.size() > 0) { + address = addresses.get(0); + } + + return address; + } + + public static String getLocationNameFromAddress(Address address) { + String locality = "", adminArea = "", country = ""; + if (address.getLocality() != null) { + locality = address.getLocality(); + } + + if (address.getAdminArea() != null) { + adminArea = address.getAdminArea(); + } + + if (address.getCountryName() != null) { + country = address.getCountryName(); + } + + return ((locality.equals("")) ? locality : locality + ", ") + + ((adminArea.equals("")) ? adminArea : adminArea + " ") + country; + } + + public static double[] getCoordsFromAddress(Address address) { + double[] coordinates = new double[2]; + + if (address.hasLatitude() && address.hasLongitude()) { + coordinates[0] = address.getLatitude(); + coordinates[1] = address.getLongitude(); + } + + return coordinates; + } +} diff --git a/WordPressUtils/src/main/java/org/wordpress/android/util/GravatarUtils.java b/WordPressUtils/src/main/java/org/wordpress/android/util/GravatarUtils.java new file mode 100644 index 000000000000..c10ce69c81e8 --- /dev/null +++ b/WordPressUtils/src/main/java/org/wordpress/android/util/GravatarUtils.java @@ -0,0 +1,22 @@ +package org.wordpress.android.util; + +import android.text.TextUtils; + +public class GravatarUtils { + /* + * see https://en.gravatar.com/site/implement/images/ + */ + public static String gravatarUrlFromEmail(final String email, int size) { + if (TextUtils.isEmpty(email)) + return ""; + + String url = "http://gravatar.com/avatar/" + + StringUtils.getMd5Hash(email) + + "?d=mm"; + + if (size > 0) + url += "&s=" + Integer.toString(size); + + return url; + } +} diff --git a/WordPressUtils/src/main/java/org/wordpress/android/util/HtmlUtils.java b/WordPressUtils/src/main/java/org/wordpress/android/util/HtmlUtils.java new file mode 100644 index 000000000000..44760b05f99c --- /dev/null +++ b/WordPressUtils/src/main/java/org/wordpress/android/util/HtmlUtils.java @@ -0,0 +1,128 @@ +package org.wordpress.android.util; + +import android.content.Context; +import android.content.res.Resources; +import android.text.Html; +import android.text.SpannableStringBuilder; +import android.text.TextUtils; +import android.text.style.QuoteSpan; + +import org.apache.commons.lang.StringEscapeUtils; + +public class HtmlUtils { + /* + * removes html from the passed string - relies on Html.fromHtml which handles invalid HTML, + * but it's very slow, so avoid using this where performance is important + */ + public static String stripHtml(final String text) { + if (TextUtils.isEmpty(text)) + return ""; + return Html.fromHtml(text).toString().trim(); + } + + /* + * this is much faster than stripHtml() but should only be used when we know the html is valid + * since the regex will be unpredictable with invalid html + */ + public static String fastStripHtml(String str) { + if (TextUtils.isEmpty(str)) { + return str; + } + + // insert a line break before P tags unless the only one is at the start + if (str.lastIndexOf(" 0) { + str = str.replaceAll("", "\n

"); + } + + // convert BR tags to line breaks + if (str.contains("", "\n"); + } + + // use regex to strip tags, then convert entities in the result + return trimStart(fastUnescapeHtml(str.replaceAll("<(.|\n)*?>", ""))); + } + + /* + * same as apache.commons.lang.StringUtils.stripStart() but also removes non-breaking + * space (160) chars + */ + private static String trimStart(final String str) { + int strLen; + if (str == null || (strLen = str.length()) == 0) { + return ""; + } + int start = 0; + while (start != strLen + && (Character.isWhitespace(str.charAt(start)) || str.charAt(start) == 160)) { + start++; + } + return str.substring(start); + } + + /* + * convert html entities to actual Unicode characters - relies on commons apache lang + */ + public static String fastUnescapeHtml(final String text) { + if (text == null || !text.contains("&")) + return text; + return StringEscapeUtils.unescapeHtml(text); + } + + /* + * converts an R.color.xxx resource to an HTML hex color + */ + public static String colorResToHtmlColor(Context context, int resId) { + try { + return String.format("#%06X", 0xFFFFFF & context.getResources().getColor(resId)); + } catch (Resources.NotFoundException e) { + return "#000000"; + } + } + + /* + * remove blocks from the passed string - added to project after noticing + * comments on posts that use the "Sociable" plugin ( http://wordpress.org/plugins/sociable/ ) + * may have a script block which contains followed by a CDATA section followed by , + * all of which will show up if we don't strip it here (example: http://cl.ly/image/0J0N3z3h1i04 ) + * first seen at http://houseofgeekery.com/2013/11/03/13-terrible-x-men-we-wont-see-in-the-movies/ + */ + public static String stripScript(final String text) { + if (text == null) + return null; + + StringBuilder sb = new StringBuilder(text); + int start = sb.indexOf(" -1) { + int end = sb.indexOf("", start); + if (end == -1) + return sb.toString(); + sb.delete(start, end+9); + start = sb.indexOf(",

    ,
    tags and replacing Emoticons with Emojis + */ + public static SpannableStringBuilder fromHtml(String source) { + SpannableStringBuilder html; + try { + html = (SpannableStringBuilder) Html.fromHtml(source, null, new WPHtmlTagHandler()); + } catch (RuntimeException runtimeException) { + // In case our tag handler fails + html = (SpannableStringBuilder) Html.fromHtml(source, null, null); + } + Emoticons.replaceEmoticonsWithEmoji(html); + QuoteSpan spans[] = html.getSpans(0, html.length(), QuoteSpan.class); + for (QuoteSpan span : spans) { + html.setSpan(new WPQuoteSpan(), html.getSpanStart(span), html.getSpanEnd(span), html.getSpanFlags( + span)); + html.removeSpan(span); + } + return html; + } +} diff --git a/WordPressUtils/src/main/java/org/wordpress/android/util/ImageUtils.java b/WordPressUtils/src/main/java/org/wordpress/android/util/ImageUtils.java new file mode 100644 index 000000000000..1435d52565d4 --- /dev/null +++ b/WordPressUtils/src/main/java/org/wordpress/android/util/ImageUtils.java @@ -0,0 +1,493 @@ +package org.wordpress.android.util; + +import android.content.ContentResolver; +import android.content.Context; +import android.database.Cursor; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.graphics.Matrix; +import android.media.ExifInterface; +import android.net.Uri; +import android.os.AsyncTask; +import android.provider.MediaStore; +import android.text.TextUtils; +import android.widget.ImageView; + +import org.apache.http.HttpEntity; +import org.apache.http.HttpResponse; +import org.apache.http.HttpStatus; +import org.apache.http.client.methods.HttpGet; +import org.apache.http.impl.client.DefaultHttpClient; + +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.lang.ref.WeakReference; + +public class ImageUtils { + public static int[] getImageSize(Uri uri, Context context){ + String path = null; + BitmapFactory.Options options = new BitmapFactory.Options(); + options.inJustDecodeBounds = true; + + if (uri.toString().contains("content:")) { + String[] projection = new String[] { MediaStore.Images.Media._ID, MediaStore.Images.Media.DATA }; + Cursor cur = context.getContentResolver().query(uri, projection, null, null, null); + if (cur != null) { + if (cur.moveToFirst()) { + int dataColumn = cur.getColumnIndex(MediaStore.Images.Media.DATA); + path = cur.getString(dataColumn); + } + cur.close(); + } + } + + if (TextUtils.isEmpty(path)) { + //The file isn't ContentResolver, or it can't be access by ContentResolver. Try to access the file directly. + path = uri.toString().replace("content://media", ""); + path = path.replace("file://", ""); + } + + BitmapFactory.decodeFile(path, options); + int imageHeight = options.outHeight; + int imageWidth = options.outWidth; + return new int[]{imageWidth, imageHeight}; + } + + // Read the orientation from ContentResolver. If it fails, read from EXIF. + public static int getImageOrientation(Context ctx, String filePath) { + Uri curStream; + int orientation = 0; + + // Remove file protocol + filePath = filePath.replace("file://", ""); + + if (!filePath.contains("content://")) + curStream = Uri.parse("content://media" + filePath); + else + curStream = Uri.parse(filePath); + + try { + Cursor cur = ctx.getContentResolver().query(curStream, new String[]{MediaStore.Images.Media.ORIENTATION}, null, null, null); + if (cur != null) { + if (cur.moveToFirst()) { + orientation = cur.getInt(cur.getColumnIndex(MediaStore.Images.Media.ORIENTATION)); + } + cur.close(); + } + } catch (Exception errReadingContentResolver) { + AppLog.e(AppLog.T.UTILS, errReadingContentResolver); + } + + if (orientation == 0) { + orientation = getExifOrientation(filePath); + } + + return orientation; + } + + + public static int getExifOrientation(String path) { + ExifInterface exif; + try { + exif = new ExifInterface(path); + } catch (IOException e) { + AppLog.e(AppLog.T.UTILS, e); + return 0; + } + + int exifOrientation = exif.getAttributeInt(ExifInterface.TAG_ORIENTATION, 0); + + switch (exifOrientation) { + case ExifInterface.ORIENTATION_NORMAL: + return 0; + case ExifInterface.ORIENTATION_ROTATE_90: + return 90; + case ExifInterface.ORIENTATION_ROTATE_180: + return 180; + case ExifInterface.ORIENTATION_ROTATE_270: + return 270; + default: + return 0; + } + } + + public static Bitmap downloadBitmap(String url) { + final DefaultHttpClient client = new DefaultHttpClient(); + + final HttpGet getRequest = new HttpGet(url); + + try { + HttpResponse response = client.execute(getRequest); + final int statusCode = response.getStatusLine().getStatusCode(); + if (statusCode != HttpStatus.SC_OK) { + AppLog.w(AppLog.T.UTILS, "ImageDownloader Error " + statusCode + + " while retrieving bitmap from " + url); + return null; + } + + final HttpEntity entity = response.getEntity(); + if (entity != null) { + InputStream inputStream = null; + try { + inputStream = entity.getContent(); + return BitmapFactory.decodeStream(inputStream); + } finally { + if (inputStream != null) { + inputStream.close(); + } + entity.consumeContent(); + } + } + } catch (Exception e) { + // Could provide a more explicit error message for IOException or + // IllegalStateException + getRequest.abort(); + AppLog.w(AppLog.T.UTILS, "ImageDownloader Error while retrieving bitmap from " + url); + } + return null; + } + + /** From http://developer.android.com/training/displaying-bitmaps/load-bitmap.html **/ + public static int calculateInSampleSize(BitmapFactory.Options options, int reqWidth, int reqHeight) { + // Raw height and width of image + final int height = options.outHeight; + final int width = options.outWidth; + int inSampleSize = 1; + + if (height > reqHeight || width > reqWidth) { + // Calculate ratios of height and width to requested height and width + final int heightRatio = Math.round((float) height / (float) reqHeight); + final int widthRatio = Math.round((float) width / (float) reqWidth); + + // Choose the smallest ratio as inSampleSize value, this will guarantee + // a final image with both dimensions larger than or equal to the + // requested height and width. + inSampleSize = heightRatio < widthRatio ? heightRatio : widthRatio; + } + + return inSampleSize; + } + + + public interface BitmapWorkerCallback { + public void onBitmapReady(String filePath, ImageView imageView, Bitmap bitmap); + } + + public static class BitmapWorkerTask extends AsyncTask { + private final WeakReference imageViewReference; + private final BitmapWorkerCallback callback; + private int targetWidth; + private int targetHeight; + private String path; + + public BitmapWorkerTask(ImageView imageView, int width, int height, BitmapWorkerCallback callback) { + // Use a WeakReference to ensure the ImageView can be garbage collected + imageViewReference = new WeakReference(imageView); + this.callback = callback; + targetWidth = width; + targetHeight = height; + } + + // Decode image in background. + @Override + protected Bitmap doInBackground(String... params) { + path = params[0]; + + BitmapFactory.Options bfo = new BitmapFactory.Options(); + bfo.inJustDecodeBounds = true; + BitmapFactory.decodeFile(path, bfo); + + bfo.inSampleSize = calculateInSampleSize(bfo, targetWidth, targetHeight); + bfo.inJustDecodeBounds = false; + + // get proper rotation + int bitmapWidth = 0; + int bitmapHeight = 0; + try { + File f = new File(path); + ExifInterface exif = new ExifInterface(f.getPath()); + int orientation = exif.getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL); + int angle = 0; + if (orientation == ExifInterface.ORIENTATION_NORMAL) { // no need to rotate + return BitmapFactory.decodeFile(path, bfo); + } else if (orientation == ExifInterface.ORIENTATION_ROTATE_90) { + angle = 90; + } else if (orientation == ExifInterface.ORIENTATION_ROTATE_180) { + angle = 180; + } else if (orientation == ExifInterface.ORIENTATION_ROTATE_270) { + angle = 270; + } + + Matrix mat = new Matrix(); + mat.postRotate(angle); + + try { + Bitmap bmp = BitmapFactory.decodeStream(new FileInputStream(f), null, bfo); + if (bmp == null) { + AppLog.e(AppLog.T.UTILS, "can't decode bitmap: " + f.getPath()); + return null; + } + bitmapWidth = bmp.getWidth(); + bitmapHeight = bmp.getHeight(); + return Bitmap.createBitmap(bmp, 0, 0, bmp.getWidth(), bmp.getHeight(), mat, true); + } catch (OutOfMemoryError oom) { + AppLog.e(AppLog.T.UTILS, "OutOfMemoryError Error in setting image: " + oom); + } + } catch (IOException e) { + AppLog.e(AppLog.T.UTILS, "Error in setting image", e); + } + + return null; + } + + // Once complete, see if ImageView is still around and set bitmap. + @Override + protected void onPostExecute(Bitmap bitmap) { + if (imageViewReference == null || bitmap == null) + return; + + final ImageView imageView = imageViewReference.get(); + + if (callback != null) + callback.onBitmapReady(path, imageView, bitmap); + + } + } + + + public static String getTitleForWPImageSpan(Context ctx, String filePath) { + if (filePath == null) + return null; + + Uri curStream; + String title; + + if (!filePath.contains("content://")) + curStream = Uri.parse("content://media" + filePath); + else + curStream = Uri.parse(filePath); + + if (filePath.contains("video")) { + return "Video"; + } else { + String[] projection = new String[] { MediaStore.Images.Thumbnails.DATA }; + + Cursor cur; + try { + cur = ctx.getContentResolver().query(curStream, projection, null, null, null); + } catch (Exception e1) { + AppLog.e(AppLog.T.UTILS, e1); + return null; + } + File jpeg; + if (cur != null) { + String thumbData = ""; + if (cur.moveToFirst()) { + int dataColumn = cur.getColumnIndex(MediaStore.Images.Media.DATA); + thumbData = cur.getString(dataColumn); + } + cur.close(); + if (thumbData == null) { + return null; + } + jpeg = new File(thumbData); + } else { + String path = filePath.toString().replace("file://", ""); + jpeg = new File(path); + } + title = jpeg.getName(); + return title; + } + } + + /** + * Resizes an image to be placed in the Post Content Editor + * + * @return resized bitmap + */ + public static Bitmap getWPImageSpanThumbnailFromFilePath(Context context, String filePath, int targetWidth) { + if (filePath == null || context == null) { + return null; + } + + Uri curUri; + if (!filePath.contains("content://")) { + curUri = Uri.parse("content://media" + filePath); + } else { + curUri = Uri.parse(filePath); + } + + if (filePath.contains("video")) { + // Load the video thumbnail from the MediaStore + int videoId = 0; + try { + videoId = Integer.parseInt(curUri.getLastPathSegment()); + } catch (NumberFormatException e) { + } + ContentResolver crThumb = context.getContentResolver(); + BitmapFactory.Options options = new BitmapFactory.Options(); + options.inSampleSize = 1; + Bitmap videoThumbnail = MediaStore.Video.Thumbnails.getThumbnail(crThumb, videoId, MediaStore.Video.Thumbnails.MINI_KIND, + options); + if (videoThumbnail != null) { + return getScaledBitmapAtLongestSide(videoThumbnail, targetWidth); + } else { + return null; + } + } else { + // Create resized bitmap + int rotation = getImageOrientation(context, filePath); + byte[] bytes = createThumbnailFromUri(context, curUri, targetWidth, null, rotation); + + if (bytes != null && bytes.length > 0) { + try { + Bitmap resizedBitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.length); + if (resizedBitmap != null) { + return getScaledBitmapAtLongestSide(resizedBitmap, targetWidth); + } + } catch (OutOfMemoryError e) { + AppLog.e(AppLog.T.UTILS, "OutOfMemoryError Error in setting image: " + e); + return null; + } + } + } + + return null; + } + + /* + Resize a bitmap to the targetSize on its longest side. + */ + public static Bitmap getScaledBitmapAtLongestSide(Bitmap bitmap, int targetSize) { + if (bitmap.getWidth() <= targetSize && bitmap.getHeight() <= targetSize) { + // Do not resize. + return bitmap; + } + + int targetWidth, targetHeight; + if (bitmap.getHeight() > bitmap.getWidth()) { + // Resize portrait bitmap + targetHeight = targetSize; + float percentage = (float) targetSize / bitmap.getHeight(); + targetWidth = (int)(bitmap.getWidth() * percentage); + } else { + // Resize landscape or square image + targetWidth = targetSize; + float percentage = (float) targetSize / bitmap.getWidth(); + targetHeight = (int)(bitmap.getHeight() * percentage); + } + + return Bitmap.createScaledBitmap(bitmap, targetWidth, targetHeight, true); + } + + /** + * nbradbury - 21-Feb-2014 - similar to createThumbnail but more efficient since it doesn't + * require passing the full-size image as an array of bytes[] + */ + public static byte[] createThumbnailFromUri(Context context, + Uri imageUri, + int maxWidth, + String fileExtension, + int rotation) { + if (context == null || imageUri == null || maxWidth <= 0) + return null; + + String filePath = null; + if (imageUri.toString().contains("content:")) { + String[] projection = new String[] { MediaStore.Images.Media.DATA }; + Cursor cur = context.getContentResolver().query(imageUri, projection, null, null, null); + if (cur != null) { + if (cur.moveToFirst()) { + int dataColumn = cur.getColumnIndex(MediaStore.Images.Media.DATA); + filePath = cur.getString(dataColumn); + } + cur.close(); + } + } + + if (TextUtils.isEmpty(filePath)) { + //access the file directly + filePath = imageUri.toString().replace("content://media", ""); + filePath = filePath.replace("file://", ""); + } + + // get just the image bounds + BitmapFactory.Options optBounds = new BitmapFactory.Options(); + optBounds.inJustDecodeBounds = true; + + try { + BitmapFactory.decodeFile(filePath, optBounds); + } catch (OutOfMemoryError e) { + AppLog.e(AppLog.T.UTILS, "OutOfMemoryError Error in setting image: " + e); + return null; + } + + // determine correct scale value (should be power of 2) + // http://stackoverflow.com/questions/477572/android-strange-out-of-memory-issue/3549021#3549021 + int scale = 1; + if (maxWidth > 0 && optBounds.outWidth > maxWidth) { + double d = Math.pow(2, (int) Math.round(Math.log(maxWidth / (double) optBounds.outWidth) / Math.log(0.5))); + scale = (int) d; + } + + BitmapFactory.Options optActual = new BitmapFactory.Options(); + optActual.inSampleSize = scale; + + // Get the roughly resized bitmap + Bitmap bmpResized; + try { + bmpResized = BitmapFactory.decodeFile(filePath, optActual); + } catch (OutOfMemoryError e) { + AppLog.e(AppLog.T.UTILS, "OutOfMemoryError Error in setting image: " + e); + return null; + } + + if (bmpResized == null) + return null; + + ByteArrayOutputStream stream = new ByteArrayOutputStream(); + + // Now calculate exact scale in order to resize accurately + float percentage = (float) maxWidth / bmpResized.getWidth(); + float proportionateHeight = bmpResized.getHeight() * percentage; + int finalHeight = (int) Math.rint(proportionateHeight); + + float scaleWidth = ((float) maxWidth) / bmpResized.getWidth(); + float scaleHeight = ((float) finalHeight) / bmpResized.getHeight(); + + float scaleBy = Math.min(scaleWidth, scaleHeight); + + // Resize the bitmap to exact size + Matrix matrix = new Matrix(); + matrix.postScale(scaleBy, scaleBy); + + // apply rotation + if (rotation != 0) { + matrix.setRotate(rotation); + } + + Bitmap.CompressFormat fmt; + if (fileExtension != null && fileExtension.equalsIgnoreCase("png")) { + fmt = Bitmap.CompressFormat.PNG; + } else { + fmt = Bitmap.CompressFormat.JPEG; + } + + final Bitmap bmpRotated; + try { + bmpRotated = Bitmap.createBitmap(bmpResized, 0, 0, bmpResized.getWidth(), bmpResized.getHeight(), matrix, + true); + } catch (OutOfMemoryError e) { + AppLog.e(AppLog.T.UTILS, "OutOfMemoryError Error in setting image: " + e); + return null; + } + bmpRotated.compress(fmt, 100, stream); + bmpResized.recycle(); + bmpRotated.recycle(); + + return stream.toByteArray(); + } +} diff --git a/WordPressUtils/src/main/java/org/wordpress/android/util/JSONUtil.java b/WordPressUtils/src/main/java/org/wordpress/android/util/JSONUtil.java new file mode 100644 index 000000000000..5e24e96072d4 --- /dev/null +++ b/WordPressUtils/src/main/java/org/wordpress/android/util/JSONUtil.java @@ -0,0 +1,236 @@ +package org.wordpress.android.util; + +import android.text.TextUtils; + +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; +import org.wordpress.android.util.AppLog.T; + +import java.util.ArrayList; + +public class JSONUtil { + private static String QUERY_SEPERATOR= ""; + private static String QUERY_ARRAY_INDEX_START="["; + private static String QUERY_ARRAY_INDEX_END="]"; + private static String QUERY_ARRAY_FIRST="first"; + private static String QUERY_ARRAY_LAST="last"; + + private static final String JSON_NULL_STR = "null"; + + private static final String TAG="JSONUtil"; + /** + * Given a JSONObject and a key path (e.g property.child) and a default it will + * traverse the object graph and pull out the desired property + */ + public static U queryJSON(JSONObject source, String query, U defaultObject) { + int nextSeperator = query.indexOf(QUERY_SEPERATOR); + int nextIndexStart = query.indexOf(QUERY_ARRAY_INDEX_START); + if (nextSeperator == -1 && nextIndexStart == -1) { + // last item let's get it + try { + if (!source.has(query)) { + return defaultObject; + } + Object result = source.get(query); + if (result.getClass().isAssignableFrom(defaultObject.getClass())) { + return (U) result; + } else { + return defaultObject; + } + } catch (java.lang.ClassCastException e) { + AppLog.e(T.UTILS, "Unable to cast the object to " + defaultObject.getClass().getName(), e); + return defaultObject; + } catch (JSONException e) { + AppLog.e(T.UTILS, "Unable to get the Key from the input object. Key:" + query, e); + return defaultObject; + } + } + int endQuery; + if (nextSeperator == -1 || nextIndexStart == -1) { + endQuery = Math.max(nextSeperator, nextIndexStart); + } else { + endQuery = Math.min(nextSeperator, nextIndexStart); + } + String nextQuery = query.substring(endQuery); + String key = query.substring(0, endQuery); + try { + if (source == null) { + return defaultObject; + } + if (nextQuery.indexOf(QUERY_SEPERATOR) == 0) { + return queryJSON(source.getJSONObject(key), nextQuery.substring(1), defaultObject); + } else if (nextQuery.indexOf(QUERY_ARRAY_INDEX_START) == 0) { + return queryJSON(source.getJSONArray(key), nextQuery, defaultObject); + } else if (!nextQuery.equals("")) { + return defaultObject; + } + Object result = source.get(key); + if (result.getClass().isAssignableFrom(defaultObject.getClass())) { + return (U) result; + } else { + AppLog.w(T.UTILS, String.format("The returned object type %s is not assignable to the type %s. Using default!", + result.getClass(),defaultObject.getClass())); + return defaultObject; + } + } catch (java.lang.ClassCastException e) { + AppLog.e(T.UTILS, "Unable to cast the object to " + defaultObject.getClass().getName(), e); + return defaultObject; + } catch (JSONException e) { + AppLog.e(T.UTILS, "Unable to get the Key from the input object. Key:" + query, e); + return defaultObject; + } + } + + /** + * Given a JSONArray and a query (e.g. [0].property) it will traverse the array and + * pull out the requested property. + * + * Acceptable indexes include negative numbers to reference items from the end of + * the list as well as "last" and "first" as more explicit references to "0" and "-1" + */ + public static U queryJSON(JSONArray source, String query, U defaultObject){ + // query must start with [ have an index and then have ] + int indexStart = query.indexOf(QUERY_ARRAY_INDEX_START); + int indexEnd = query.indexOf(QUERY_ARRAY_INDEX_END); + if (indexStart == -1 || indexEnd == -1 || indexStart > indexEnd) { + return defaultObject; + } + // get "index" from "[index]" + String indexStr = query.substring(indexStart + 1, indexEnd); + int index; + if (indexStr.equals(QUERY_ARRAY_FIRST)) { + index = 0; + } else if (indexStr.equals(QUERY_ARRAY_LAST)) { + index = -1; + } else { + index = Integer.parseInt(indexStr); + } + if (index < 0) { + index = source.length() + index; + } + // copy remaining query + String remainingQuery = query.substring(indexEnd + 1); + try { + if (remainingQuery.indexOf(QUERY_ARRAY_INDEX_START) == 0) { + return queryJSON(source.getJSONArray(index), remainingQuery, defaultObject); + } else if (remainingQuery.indexOf(QUERY_SEPERATOR) == 0) { + return queryJSON(source.getJSONObject(index), remainingQuery.substring(1), defaultObject); + } else if (!remainingQuery.equals("")) { + // TODO throw an exception since the query isn't valid? + AppLog.w(T.UTILS, String.format("Incorrect query for next object %s", remainingQuery)); + return defaultObject; + } + Object result = source.get(index); + if (result.getClass().isAssignableFrom(defaultObject.getClass())) { + return (U) result; + } else { + AppLog.w(T.UTILS, String.format("The returned object type %s is not assignable to the type %s. Using default!", + result.getClass(),defaultObject.getClass())); + return defaultObject; + } + } catch (java.lang.ClassCastException e) { + AppLog.e(T.UTILS, "Unable to cast the object to "+defaultObject.getClass().getName(), e); + return defaultObject; + } catch (JSONException e) { + AppLog.e(T.UTILS, "Unable to get the Key from the input object. Key:" + query, e); + return defaultObject; + } + } + + /** + * Convert a JSONArray (expected to contain strings) in a string list + */ + public static ArrayList fromJSONArrayToStringList(JSONArray jsonArray) { + ArrayList stringList = new ArrayList(); + for (int i = 0; i < jsonArray.length(); i++) { + try { + stringList.add(jsonArray.getString(i)); + } catch (JSONException e) { + AppLog.e(T.UTILS, e); + } + } + return stringList; + } + + /** + * Convert a string list in a JSONArray + */ + public static JSONArray fromStringListToJSONArray(ArrayList stringList) { + JSONArray jsonArray = new JSONArray(); + if (stringList != null) { + for (int i = 0; i < stringList.size(); i++) { + jsonArray.put(stringList.get(i)); + } + } + return jsonArray; + } + + /* + * wrapper for JSONObject.optString() which handles "null" values + */ + public static String getString(JSONObject json, String name) { + String value = json.optString(name); + // return empty string for "null" + if (JSON_NULL_STR.equals(value)) + return ""; + return value; + } + + /* + * use with strings that contain HTML entities + */ + public static String getStringDecoded(JSONObject json, String name) { + String value = getString(json, name); + return HtmlUtils.fastUnescapeHtml(value); + } + + /* + * replacement for JSONObject.optBoolean() - optBoolean() only checks for "true" and "false", + * but our API sometimes uses "0" to denote false + */ + public static boolean getBool(JSONObject json, String name) { + String value = getString(json, name); + if (TextUtils.isEmpty(value)) + return false; + if (value.equals("0")) + return false; + if (value.equalsIgnoreCase("false")) + return false; + return true; + } + + /* + * returns the JSONObject child of the passed parent that matches the passed query + * this is basically an "optJSONObject" that supports nested queries, for example: + * + * getJSONChild("meta/data/site") + * + * would find this: + * + * "meta": { + * "data": { + * "site": { + * "ID": 3584907, + * "name": "WordPress.com News", + * } + * } + * } + */ + public static JSONObject getJSONChild(final JSONObject jsonParent, final String query) { + if (jsonParent == null || TextUtils.isEmpty(query)) + return null; + String[] names = query.split("/"); + JSONObject jsonChild = null; + for (int i = 0; i < names.length; i++) { + if (jsonChild == null) { + jsonChild = jsonParent.optJSONObject(names[i]); + } else { + jsonChild = jsonChild.optJSONObject(names[i]); + } + if (jsonChild == null) + return null; + } + return jsonChild; + } +} \ No newline at end of file diff --git a/WordPressUtils/src/main/java/org/wordpress/android/util/ListScrollPositionManager.java b/WordPressUtils/src/main/java/org/wordpress/android/util/ListScrollPositionManager.java new file mode 100644 index 000000000000..d60e9da6c6b2 --- /dev/null +++ b/WordPressUtils/src/main/java/org/wordpress/android/util/ListScrollPositionManager.java @@ -0,0 +1,36 @@ +package org.wordpress.android.util; + +import android.view.View; +import android.widget.ListView; + +public class ListScrollPositionManager { + private int mSelectedPosition; + private int mListViewScrollStateIndex; + private int mListViewScrollStateOffset; + private ListView mListView; + private boolean mSetSelection; + + public ListScrollPositionManager(ListView listView, boolean setSelection) { + mListView = listView; + mSetSelection = setSelection; + } + + public void saveScrollOffset() { + mListViewScrollStateIndex = mListView.getFirstVisiblePosition(); + View view = mListView.getChildAt(0); + mListViewScrollStateOffset = 0; + if (view != null) { + mListViewScrollStateOffset = view.getTop(); + } + if (mSetSelection) { + mSelectedPosition = mListView.getCheckedItemPosition(); + } + } + + public void restoreScrollOffset() { + mListView.setSelectionFromTop(mListViewScrollStateIndex, mListViewScrollStateOffset); + if (mSetSelection) { + mListView.setItemChecked(mSelectedPosition, true); + } + } +} diff --git a/WordPressUtils/src/main/java/org/wordpress/android/util/LocationHelper.java b/WordPressUtils/src/main/java/org/wordpress/android/util/LocationHelper.java new file mode 100644 index 000000000000..12439fd28c87 --- /dev/null +++ b/WordPressUtils/src/main/java/org/wordpress/android/util/LocationHelper.java @@ -0,0 +1,132 @@ +//This Handy-Dandy class acquired and tweaked from http://stackoverflow.com/a/3145655/309558 +package org.wordpress.android.util; + +import java.util.Timer; +import java.util.TimerTask; + +import android.content.Context; +import android.location.Location; +import android.location.LocationListener; +import android.location.LocationManager; +import android.os.Bundle; + +public class LocationHelper { + Timer timer1; + LocationManager lm; + LocationResult locationResult; + boolean gps_enabled = false; + boolean network_enabled = false; + + public boolean getLocation(Context context, LocationResult result) { + locationResult = result; + if (lm == null) + lm = (LocationManager) context + .getSystemService(Context.LOCATION_SERVICE); + + // exceptions will be thrown if provider is not permitted. + try { + gps_enabled = lm.isProviderEnabled(LocationManager.GPS_PROVIDER); + } catch (Exception ex) { + } + try { + network_enabled = lm.isProviderEnabled(LocationManager.NETWORK_PROVIDER); + } catch (Exception ex) { + } + + // don't start listeners if no provider is enabled + if (!gps_enabled && !network_enabled) + return false; + + if (gps_enabled) + lm.requestLocationUpdates(LocationManager.GPS_PROVIDER, 0, 0, locationListenerGps); + + if (network_enabled) + lm.requestLocationUpdates(LocationManager.NETWORK_PROVIDER, 0, 0, locationListenerNetwork); + + timer1 = new Timer(); + timer1.schedule(new GetLastLocation(), 30000); + return true; + } + + LocationListener locationListenerGps = new LocationListener() { + public void onLocationChanged(Location location) { + timer1.cancel(); + locationResult.gotLocation(location); + lm.removeUpdates(this); + lm.removeUpdates(locationListenerNetwork); + } + + public void onProviderDisabled(String provider) { + } + + public void onProviderEnabled(String provider) { + } + + public void onStatusChanged(String provider, int status, Bundle extras) { + } + }; + + LocationListener locationListenerNetwork = new LocationListener() { + public void onLocationChanged(Location location) { + timer1.cancel(); + locationResult.gotLocation(location); + lm.removeUpdates(this); + lm.removeUpdates(locationListenerGps); + } + + public void onProviderDisabled(String provider) { + } + + public void onProviderEnabled(String provider) { + } + + public void onStatusChanged(String provider, int status, Bundle extras) { + } + }; + + class GetLastLocation extends TimerTask { + @Override + public void run() { + lm.removeUpdates(locationListenerGps); + lm.removeUpdates(locationListenerNetwork); + + Location net_loc = null, gps_loc = null; + if (gps_enabled) + gps_loc = lm.getLastKnownLocation(LocationManager.GPS_PROVIDER); + if (network_enabled) + net_loc = lm + .getLastKnownLocation(LocationManager.NETWORK_PROVIDER); + + // if there are both values use the latest one + if (gps_loc != null && net_loc != null) { + if (gps_loc.getTime() > net_loc.getTime()) + locationResult.gotLocation(gps_loc); + else + locationResult.gotLocation(net_loc); + return; + } + + if (gps_loc != null) { + locationResult.gotLocation(gps_loc); + return; + } + if (net_loc != null) { + locationResult.gotLocation(net_loc); + return; + } + locationResult.gotLocation(null); + } + } + + public static abstract class LocationResult { + public abstract void gotLocation(Location location); + } + + public void cancelTimer() { + if (timer1 != null) { + timer1.cancel(); + lm.removeUpdates(locationListenerGps); + lm.removeUpdates(locationListenerNetwork); + } + } +} diff --git a/WordPressUtils/src/main/java/org/wordpress/android/util/MapUtils.java b/WordPressUtils/src/main/java/org/wordpress/android/util/MapUtils.java new file mode 100644 index 000000000000..981e537d257a --- /dev/null +++ b/WordPressUtils/src/main/java/org/wordpress/android/util/MapUtils.java @@ -0,0 +1,79 @@ +package org.wordpress.android.util; + +import java.util.Date; +import java.util.Map; + +/** + * wrappers for extracting values from a Map object + */ +public class MapUtils { + /* + * returns a String value for the passed key in the passed map + * always returns "" instead of null + */ + public static String getMapStr(final Map map, final String key) { + if (map == null || key == null || !map.containsKey(key) || map.get(key) == null) { + return ""; + } + return map.get(key).toString(); + } + + /* + * returns an int value for the passed key in the passed map + * defaultValue is returned if key doesn't exist or isn't a number + */ + public static int getMapInt(final Map map, final String key) { + return getMapInt(map, key, 0); + } + public static int getMapInt(final Map map, final String key, int defaultValue) { + try { + return Integer.parseInt(getMapStr(map, key)); + } catch (NumberFormatException e) { + return defaultValue; + } + } + + /* + * long version of above + */ + public static long getMapLong(final Map map, final String key) { + return getMapLong(map, key, 0); + } + public static long getMapLong(final Map map, final String key, long defaultValue) { + try { + return Long.parseLong(getMapStr(map, key)); + } catch (NumberFormatException e) { + return defaultValue; + } + } + + /* + * returns a date object from the passed key in the passed map + * returns null if key doesn't exist or isn't a date + */ + public static Date getMapDate(final Map map, final String key) { + if (map==null || key==null || !map.containsKey(key)) + return null; + try { + return (Date) map.get(key); + } catch (ClassCastException e) { + return null; + } + } + + /* + * returns a boolean value from the passed key in the passed map + * returns true unless key doesn't exist, or the value is "0" or "false" + */ + public static boolean getMapBool(final Map map, final String key) { + String value = getMapStr(map, key); + if (value.isEmpty()) + return false; + if (value.startsWith("0")) // handles "0" and "0.0" + return false; + if (value.equalsIgnoreCase("false")) + return false; + // all other values are assume to be true + return true; + } +} diff --git a/WordPressUtils/src/main/java/org/wordpress/android/util/PhotonUtils.java b/WordPressUtils/src/main/java/org/wordpress/android/util/PhotonUtils.java new file mode 100644 index 000000000000..497d756ee377 --- /dev/null +++ b/WordPressUtils/src/main/java/org/wordpress/android/util/PhotonUtils.java @@ -0,0 +1,96 @@ +package org.wordpress.android.util; + +import android.text.TextUtils; + +/** + * routines related to the Photon API + * http://developer.wordpress.com/docs/photon/ + */ +public class PhotonUtils { + private PhotonUtils() { + throw new AssertionError(); + } + + /* + * gravatars often contain the ?s= parameter which determines their size - detect this and + * replace it with a new ?s= parameter which requests the avatar at the exact size needed + */ + public static String fixAvatar(final String imageUrl, int avatarSz) { + if (TextUtils.isEmpty(imageUrl)) + return ""; + + // if this isn't a gravatar image, return as resized photon image url + if (!imageUrl.contains("gravatar.com")) + return getPhotonImageUrl(imageUrl, avatarSz, avatarSz); + + // remove all other params, then add query string for size and "mystery man" default + return UrlUtils.removeQuery(imageUrl) + String.format("?s=%d&d=mm", avatarSz); + } + + /* + * returns true if the passed url is an obvious "mshots" url + */ + public static boolean isMshotsUrl(final String imageUrl) { + return (imageUrl != null && imageUrl.contains("/mshots/")); + } + + /* + * returns a photon url for the passed image with the resize query set to the passed dimensions + */ + public static String getPhotonImageUrl(String imageUrl, int width, int height) { + if (TextUtils.isEmpty(imageUrl)) { + return ""; + } + + // make sure it's valid + int schemePos = imageUrl.indexOf("://"); + if (schemePos == -1) { + return imageUrl; + } + + // remove existing query string since it may contain params that conflict with the passed ones + imageUrl = UrlUtils.removeQuery(imageUrl); + + // don't use with GIFs - photon breaks animated GIFs, and sometimes returns a GIF that + // can't be read by BitmapFactory.decodeByteArray (used by Volley in ImageRequest.java + // to decode the downloaded image) + // ex: http://i0.wp.com/lusianne.files.wordpress.com/2013/08/193.gif?resize=768,320 + if (imageUrl.endsWith(".gif")) { + return imageUrl; + } + + // if this is an "mshots" url, skip photon and return it with a query that sets the width/height + // (these are screenshots of the blog that often appear in freshly pressed posts) + // see http://wp.tutsplus.com/tutorials/how-to-generate-website-screenshots-for-your-wordpress-site/ + // ex: http://s.wordpress.com/mshots/v1/http%3A%2F%2Fnickbradbury.com?w=600 + if (isMshotsUrl(imageUrl)) { + return imageUrl + String.format("?w=%d&h=%d", width, height); + } + + // if both width & height are passed use the "resize" param, use only "w" or "h" if just + // one of them is set, otherwise no query string + final String query; + if (width > 0 && height > 0) { + query = String.format("?resize=%d,%d", width, height); + } else if (width > 0) { + query = String.format("?w=%d", width); + } else if (height > 0) { + query = String.format("?h=%d", height); + } else { + query = ""; + } + + // return passed url+query if it's already a photon url + if (imageUrl.contains(".wp.com")) { + if (imageUrl.contains("i0.wp.com") || imageUrl.contains("i1.wp.com") || imageUrl.contains("i2.wp.com")) + return imageUrl + query; + } + + // must use https for https image urls + if (UrlUtils.isHttps(imageUrl)) { + return "https://i0.wp.com/" + imageUrl.substring(schemePos+3, imageUrl.length()) + query; + } else { + return "http://i0.wp.com/" + imageUrl.substring(schemePos+3, imageUrl.length()) + query; + } + } +} diff --git a/WordPressUtils/src/main/java/org/wordpress/android/util/ProfilingUtils.java b/WordPressUtils/src/main/java/org/wordpress/android/util/ProfilingUtils.java new file mode 100644 index 000000000000..251db2a3b7fb --- /dev/null +++ b/WordPressUtils/src/main/java/org/wordpress/android/util/ProfilingUtils.java @@ -0,0 +1,91 @@ +package org.wordpress.android.util; + +import android.content.Context; +import android.content.pm.PackageInfo; +import android.content.pm.PackageManager; +import android.os.SystemClock; + +import org.wordpress.android.util.AppLog.T; + +import java.util.ArrayList; + +/** + * forked from android.util.TimingLogger to use AppLog instead of Log + new static interface. + */ +public class ProfilingUtils { + private static ProfilingUtils sInstance; + + private String mLabel; + private ArrayList mSplits; + private ArrayList mSplitLabels; + + public static void start(String label) { + getInstance().reset(label); + } + + public static void split(String splitLabel) { + getInstance().addSplit(splitLabel); + } + + public static void dump() { + getInstance().dumpToLog(); + } + + private static ProfilingUtils getInstance() { + if (sInstance == null) { + sInstance = new ProfilingUtils(); + } + return sInstance; + } + + public ProfilingUtils() { + reset("init"); + } + + public void reset(String label) { + mLabel = label; + reset(); + } + + public void reset() { + if (mSplits == null) { + mSplits = new ArrayList(); + mSplitLabels = new ArrayList(); + } else { + mSplits.clear(); + mSplitLabels.clear(); + } + addSplit(null); + } + + public void addSplit(String splitLabel) { + long now = SystemClock.elapsedRealtime(); + mSplits.add(now); + mSplitLabels.add(splitLabel); + } + + public void dumpToLog() { + AppLog.d(T.PROFILING, mLabel + ": begin"); + final long first = mSplits.get(0); + long now = first; + for (int i = 1; i < mSplits.size(); i++) { + now = mSplits.get(i); + final String splitLabel = mSplitLabels.get(i); + final long prev = mSplits.get(i - 1); + AppLog.d(T.PROFILING, mLabel + ": " + (now - prev) + " ms, " + splitLabel); + } + AppLog.d(T.PROFILING, mLabel + ": end, " + (now - first) + " ms"); + } + + // Returns app version name String + public static String getVersionName(Context context) { + PackageManager pm = context.getPackageManager(); + try { + PackageInfo pi = pm.getPackageInfo(context.getPackageName(), 0); + return pi.versionName == null ? "" : pi.versionName; + } catch (PackageManager.NameNotFoundException e) { + return ""; + } + } +} + diff --git a/WordPressUtils/src/main/java/org/wordpress/android/util/SqlUtils.java b/WordPressUtils/src/main/java/org/wordpress/android/util/SqlUtils.java new file mode 100644 index 000000000000..8d1b4b4379c9 --- /dev/null +++ b/WordPressUtils/src/main/java/org/wordpress/android/util/SqlUtils.java @@ -0,0 +1,121 @@ +package org.wordpress.android.util; + +import android.database.Cursor; +import android.database.DatabaseUtils; +import android.database.sqlite.SQLiteDatabase; +import android.database.sqlite.SQLiteDoneException; +import android.database.sqlite.SQLiteException; +import android.database.sqlite.SQLiteStatement; + +import java.util.ArrayList; +import java.util.List; + +public class SqlUtils { + private SqlUtils() { + throw new AssertionError(); + } + + /* + * SQLite doesn't have a boolean datatype, so booleans are stored as 0=false, 1=true + */ + public static long boolToSql(boolean value) { + return (value ? 1 : 0); + } + public static boolean sqlToBool(int value) { + return (value != 0); + } + + public static void closeStatement(SQLiteStatement stmt) { + if (stmt != null) { + stmt.close(); + } + } + + public static void closeCursor(Cursor c) { + if (c != null && !c.isClosed()) { + c.close(); + } + } + + /* + * wrapper for DatabaseUtils.longForQuery() which returns 0 if query returns no rows + */ + public static long longForQuery(SQLiteDatabase db, String query, String[] selectionArgs) { + try { + return DatabaseUtils.longForQuery(db, query, selectionArgs); + } catch (SQLiteDoneException e) { + return 0; + } + } + + public static int intForQuery(SQLiteDatabase db, String query, String[] selectionArgs) { + long value = longForQuery(db, query, selectionArgs); + return (int)value; + } + + public static boolean boolForQuery(SQLiteDatabase db, String query, String[] selectionArgs) { + long value = longForQuery(db, query, selectionArgs); + return sqlToBool((int) value); + } + + /* + * wrapper for DatabaseUtils.stringForQuery(), returns "" if query returns no rows + */ + public static String stringForQuery(SQLiteDatabase db, String query, String[] selectionArgs) { + try { + return DatabaseUtils.stringForQuery(db, query, selectionArgs); + } catch (SQLiteDoneException e) { + return ""; + } + } + + /* + * returns the number of rows in the passed table + */ + public static long getRowCount(SQLiteDatabase db, String tableName) { + return DatabaseUtils.queryNumEntries(db, tableName); + } + + /* + * removes all rows from the passed table + */ + public static void deleteAllRowsInTable(SQLiteDatabase db, String tableName) { + db.delete(tableName, null, null); + } + + /* + * drop all tables from the passed SQLiteDatabase - make sure to pass a + * writable database + */ + public static boolean dropAllTables(SQLiteDatabase db) throws SQLiteException { + if (db == null) { + return false; + } + + if (db.isReadOnly()) { + throw new SQLiteException("can't drop tables from a read-only database"); + } + + List tableNames = new ArrayList(); + Cursor cursor = db.rawQuery("SELECT name FROM sqlite_master WHERE type='table'", null); + if (cursor.moveToFirst()) { + do { + String tableName = cursor.getString(0); + if (!tableName.equals("android_metadata") && !tableName.equals("sqlite_sequence")) { + tableNames.add(tableName); + } + } while (cursor.moveToNext()); + } + + db.beginTransaction(); + try { + for (String tableName: tableNames) { + db.execSQL("DROP TABLE IF EXISTS " + tableName); + } + db.setTransactionSuccessful(); + return true; + } finally { + db.endTransaction(); + } + } +} diff --git a/WordPressUtils/src/main/java/org/wordpress/android/util/StringUtils.java b/WordPressUtils/src/main/java/org/wordpress/android/util/StringUtils.java new file mode 100644 index 000000000000..eca31ffd169d --- /dev/null +++ b/WordPressUtils/src/main/java/org/wordpress/android/util/StringUtils.java @@ -0,0 +1,278 @@ +package org.wordpress.android.util; + +import android.text.Html; +import android.text.TextUtils; + +import org.wordpress.android.util.AppLog.T; + +import java.math.BigInteger; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +public class StringUtils { + public static String[] mergeStringArrays(String array1[], String array2[]) { + if (array1 == null || array1.length == 0) { + return array2; + } + if (array2 == null || array2.length == 0) { + return array1; + } + List array1List = Arrays.asList(array1); + List array2List = Arrays.asList(array2); + List result = new ArrayList(array1List); + List tmp = new ArrayList(array1List); + tmp.retainAll(array2List); + result.addAll(array2List); + return ((String[]) result.toArray(new String[result.size()])); + } + + public static String convertHTMLTagsForUpload(String source) { + // bold + source = source.replace("", ""); + source = source.replace("", ""); + + // italics + source = source.replace("", ""); + source = source.replace("", ""); + + return source; + } + + public static String convertHTMLTagsForDisplay(String source) { + // bold + source = source.replace("", ""); + source = source.replace("", ""); + + // italics + source = source.replace("", ""); + source = source.replace("", ""); + + return source; + } + + public static String addPTags(String source) { + String[] asploded = source.split("\n\n"); + + if (asploded.length > 0) { + StringBuilder wrappedHTML = new StringBuilder(); + for (int i = 0; i < asploded.length; i++) { + String trimmed = asploded[i].trim(); + if (trimmed.length() > 0) { + trimmed = trimmed.replace("
    ", "
    ").replace("
    ", "
    ").replace("
    \n", "
    ") + .replace("\n", "
    "); + wrappedHTML.append("

    "); + wrappedHTML.append(trimmed); + wrappedHTML.append("

    "); + } + } + return wrappedHTML.toString(); + } else { + return source; + } + } + + public static BigInteger getMd5IntHash(String input) { + try { + MessageDigest md = MessageDigest.getInstance("MD5"); + byte[] messageDigest = md.digest(input.getBytes()); + BigInteger number = new BigInteger(1, messageDigest); + return number; + } catch (NoSuchAlgorithmException e) { + AppLog.e(T.UTILS, e); + return null; + } + } + + public static String getMd5Hash(String input) { + BigInteger number = getMd5IntHash(input); + String md5 = number.toString(16); + while (md5.length() < 32) { + md5 = "0" + md5; + } + return md5; + } + + public static String unescapeHTML(String html) { + if (html != null) { + return Html.fromHtml(html).toString(); + } else { + return ""; + } + } + + /* + * nbradbury - adapted from Html.escapeHtml(), which was added in API Level 16 + * TODO: not thoroughly tested yet, so marked as private - not sure I like the way + * this replaces two spaces with " " + */ + private static String escapeHtml(final String text) { + if (text == null) { + return ""; + } + + StringBuilder out = new StringBuilder(); + int length = text.length(); + + for (int i = 0; i < length; i++) { + char c = text.charAt(i); + + if (c == '<') { + out.append("<"); + } else if (c == '>') { + out.append(">"); + } else if (c == '&') { + out.append("&"); + } else if (c > 0x7E || c < ' ') { + out.append("&#").append((int) c).append(";"); + } else if (c == ' ') { + while (i + 1 < length && text.charAt(i + 1) == ' ') { + out.append(" "); + i++; + } + + out.append(' '); + } else { + out.append(c); + } + } + + return out.toString(); + } + + /* + * returns empty string if passed string is null, otherwise returns passed string + */ + public static String notNullStr(String s) { + if (s == null) { + return ""; + } + return s; + } + + /** + * returns true if two strings are equal or two strings are null + */ + public static boolean equals(String s1, String s2) { + if (s1 == null) { + return s2 == null; + } + return s1.equals(s2); + } + + /* + * capitalizes the first letter in the passed string - based on Apache commons/lang3/StringUtils + * http://svn.apache.org/viewvc/commons/proper/lang/trunk/src/main/java/org/apache/commons/lang3/StringUtils.java?revision=1497829&view=markup + */ + public static String capitalize(final String str) { + int strLen; + if (str == null || (strLen = str.length()) == 0) { + return str; + } + + char firstChar = str.charAt(0); + if (Character.isTitleCase(firstChar)) { + return str; + } + + return new StringBuilder(strLen).append(Character.toTitleCase(firstChar)).append(str.substring(1)).toString(); + } + + /* + * Wrap an image URL in a photon URL + * Check out http://developer.wordpress.com/docs/photon/ + */ + public static String getPhotonUrl(String imageUrl, int size) { + imageUrl = imageUrl.replace("http://", "").replace("https://", ""); + return "http://i0.wp.com/" + imageUrl + "?w=" + size; + } + + public static String getHost(String url) { + if (TextUtils.isEmpty(url)) { + return ""; + } + + int doubleslash = url.indexOf("//"); + if (doubleslash == -1) { + doubleslash = 0; + } else { + doubleslash += 2; + } + + int end = url.indexOf('/', doubleslash); + end = (end >= 0) ? end : url.length(); + + return url.substring(doubleslash, end); + } + + public static String replaceUnicodeSurrogateBlocksWithHTMLEntities(final String inputString) { + final int length = inputString.length(); + StringBuilder out = new StringBuilder(); // Used to hold the output. + for (int offset = 0; offset < length; ) { + final int codepoint = inputString.codePointAt(offset); + final char current = inputString.charAt(offset); + if (Character.isHighSurrogate(current) || Character.isLowSurrogate(current)) { + if (Emoticons.wpSmiliesCodePointToText.get(codepoint) != null) { + out.append(Emoticons.wpSmiliesCodePointToText.get(codepoint)); + } else { + final String htmlEscapedChar = "&#x" + Integer.toHexString(codepoint) + ";"; + out.append(htmlEscapedChar); + } + } else { + out.append(current); + } + offset += Character.charCount(codepoint); + } + return out.toString(); + } + + /** + * This method ensures that the output String has only + * valid XML unicode characters as specified by the + * XML 1.0 standard. For reference, please see + * the + * standard. This method will return an empty + * String if the input is null or empty. + * + * @param in The String whose non-valid characters we want to remove. + * @return The in String, stripped of non-valid characters. + */ + public static final String stripNonValidXMLCharacters(String in) { + StringBuilder out = new StringBuilder(); // Used to hold the output. + char current; // Used to reference the current character. + + if (in == null || ("".equals(in))) { + return ""; // vacancy test. + } + for (int i = 0; i < in.length(); i++) { + current = in.charAt(i); // NOTE: No IndexOutOfBoundsException caught here; it should not happen. + if ((current == 0x9) || + (current == 0xA) || + (current == 0xD) || + ((current >= 0x20) && (current <= 0xD7FF)) || + ((current >= 0xE000) && (current <= 0xFFFD)) || + ((current >= 0x10000) && (current <= 0x10FFFF))) { + out.append(current); + } + } + return out.toString(); + } + + /* + * simple wrapper for Integer.valueOf(string) so caller doesn't need to catch NumberFormatException + */ + public static int stringToInt(String s) { + return stringToInt(s, 0); + } + public static int stringToInt(String s, int defaultValue) { + if (s == null) + return defaultValue; + try { + return Integer.valueOf(s); + } catch (NumberFormatException e) { + return defaultValue; + } + } +} \ No newline at end of file diff --git a/WordPressUtils/src/main/java/org/wordpress/android/util/SystemServiceFactory.java b/WordPressUtils/src/main/java/org/wordpress/android/util/SystemServiceFactory.java new file mode 100644 index 000000000000..4ba0c96ed589 --- /dev/null +++ b/WordPressUtils/src/main/java/org/wordpress/android/util/SystemServiceFactory.java @@ -0,0 +1,17 @@ +package org.wordpress.android.util; + +import android.content.Context; + +import org.wordpress.android.util.AppLog.T; + +public class SystemServiceFactory { + public static SystemServiceFactoryAbstract sFactory; + + public static Object get(Context context, String name) { + if (sFactory == null) { + sFactory = new SystemServiceFactoryDefault(); + } + AppLog.v(T.UTILS, "instantiate SystemService using sFactory: " + sFactory.getClass()); + return sFactory.get(context, name); + } +} diff --git a/WordPressUtils/src/main/java/org/wordpress/android/util/SystemServiceFactoryAbstract.java b/WordPressUtils/src/main/java/org/wordpress/android/util/SystemServiceFactoryAbstract.java new file mode 100644 index 000000000000..a9d522db4c1c --- /dev/null +++ b/WordPressUtils/src/main/java/org/wordpress/android/util/SystemServiceFactoryAbstract.java @@ -0,0 +1,7 @@ +package org.wordpress.android.util; + +import android.content.Context; + +public interface SystemServiceFactoryAbstract { + public Object get(Context context, String name); +} diff --git a/WordPressUtils/src/main/java/org/wordpress/android/util/SystemServiceFactoryDefault.java b/WordPressUtils/src/main/java/org/wordpress/android/util/SystemServiceFactoryDefault.java new file mode 100644 index 000000000000..eb488dde9bf4 --- /dev/null +++ b/WordPressUtils/src/main/java/org/wordpress/android/util/SystemServiceFactoryDefault.java @@ -0,0 +1,9 @@ +package org.wordpress.android.util; + +import android.content.Context; + +public class SystemServiceFactoryDefault implements SystemServiceFactoryAbstract { + public Object get(Context context, String name) { + return context.getSystemService(name); + } +} diff --git a/WordPressUtils/src/main/java/org/wordpress/android/util/UrlUtils.java b/WordPressUtils/src/main/java/org/wordpress/android/util/UrlUtils.java new file mode 100644 index 000000000000..4438b8950158 --- /dev/null +++ b/WordPressUtils/src/main/java/org/wordpress/android/util/UrlUtils.java @@ -0,0 +1,165 @@ +package org.wordpress.android.util; + +import android.net.Uri; +import android.webkit.MimeTypeMap; +import android.webkit.URLUtil; + +import java.io.UnsupportedEncodingException; +import java.net.IDN; +import java.net.URI; +import java.net.URLDecoder; +import java.net.URLEncoder; +import java.nio.charset.Charset; + +public class UrlUtils { + public static String urlEncode(final String text) { + try { + return URLEncoder.encode(text, "UTF-8"); + } catch (UnsupportedEncodingException e) { + return text; + } + } + + public static String urlDecode(final String text) { + try { + return URLDecoder.decode(text, "UTF-8"); + } catch (UnsupportedEncodingException e) { + return text; + } + } + + public static String getDomainFromUrl(final String urlString) { + if (urlString == null) { + return ""; + } + Uri uri = Uri.parse(urlString); + return uri.getHost(); + } + + /** + * Convert IDN names to punycode if necessary + */ + public static String convertUrlToPunycodeIfNeeded(String url) { + if (!Charset.forName("US-ASCII").newEncoder().canEncode(url)) { + if (url.toLowerCase().startsWith("http://")) { + url = "http://" + IDN.toASCII(url.substring(7)); + } else if (url.toLowerCase().startsWith("https://")) { + url = "https://" + IDN.toASCII(url.substring(8)); + } else { + url = IDN.toASCII(url); + } + } + return url; + } + + public static String addUrlSchemeIfNeeded(String url, boolean isHTTPS) { + if (url == null) { + return null; + } + + if (!URLUtil.isValidUrl(url)) { + if (!(url.toLowerCase().startsWith("http://")) && !(url.toLowerCase().startsWith("https://"))) { + url = (isHTTPS ? "https" : "http") + "://" + url; + } + } + + return url; + } + + /** + * normalizes a URL, primarily for comparison purposes, for example so that + * normalizeUrl("http://google.com/") = normalizeUrl("http://google.com") + */ + public static String normalizeUrl(final String urlString) { + if (urlString == null) { + return null; + } + + // this routine is called from some performance-critical code and creating a URI from a string + // is slow, so skip it when possible - if we know it's not a relative path (and 99.9% of the + // time it won't be for our purposes) then we can normalize it without java.net.URI.normalize() + if (urlString.startsWith("http") && !urlString.contains("build/intermediates/exploded-aar/org.wordpress/graphview/3.1.1")) { + // return without a trailing slash + if (urlString.endsWith("/")) { + return urlString.substring(0, urlString.length() - 1); + } + return urlString; + } + + // url is relative, so fall back to using slower java.net.URI normalization + try { + URI uri = URI.create(urlString); + return uri.normalize().toString(); + } catch (IllegalArgumentException e) { + return urlString; + } + } + + /** + * returns the passed url without the query parameters + */ + public static String removeQuery(final String urlString) { + if (urlString == null) { + return null; + } + int pos = urlString.indexOf("?"); + if (pos == -1) { + return urlString; + } + return urlString.substring(0, pos); + } + + /** + * returns true if passed url is https: + */ + public static boolean isHttps(final String urlString) { + return (urlString != null && urlString.startsWith("https:")); + } + + /** + * returns https: version of passed http: url + */ + public static String makeHttps(final String urlString) { + if (urlString == null || !urlString.startsWith("http:")) { + return urlString; + } + return "https:" + urlString.substring(5, urlString.length()); + } + + /** + * see http://stackoverflow.com/a/8591230/1673548 + */ + public static String getUrlMimeType(final String urlString) { + if (urlString == null) { + return null; + } + + String extension = MimeTypeMap.getFileExtensionFromUrl(urlString); + if (extension == null) { + return null; + } + + MimeTypeMap mime = MimeTypeMap.getSingleton(); + String mimeType = mime.getMimeTypeFromExtension(extension); + if (mimeType == null) { + return null; + } + + return mimeType; + } + + /** + * returns false if the url is not valid or if the url host is null, else true + */ + public static boolean isValidUrlAndHostNotNull(String url) { + try { + URI uri = URI.create(url); + if (uri.getHost() == null) { + return false; + } + } catch (IllegalArgumentException e) { + return false; + } + return true; + } +} diff --git a/WordPressUtils/src/main/java/org/wordpress/android/util/UserEmail.java b/WordPressUtils/src/main/java/org/wordpress/android/util/UserEmail.java new file mode 100644 index 000000000000..f229d4ae1043 --- /dev/null +++ b/WordPressUtils/src/main/java/org/wordpress/android/util/UserEmail.java @@ -0,0 +1,30 @@ +package org.wordpress.android.util; + +import android.accounts.Account; +import android.accounts.AccountManager; +import android.content.Context; +import android.util.Patterns; + +import java.util.regex.Pattern; + +public class UserEmail { + public static String getPrimaryEmail(Context context) { + try { + AccountManager accountManager = AccountManager.get(context); + if (accountManager == null) + return ""; + Account[] accounts = accountManager.getAccounts(); + Pattern emailPattern = Patterns.EMAIL_ADDRESS; + for (Account account : accounts) { + // make sure account.name is an email address before adding to the list + if (emailPattern.matcher(account.name).matches()) { + return account.name; + } + } + return ""; + } catch (SecurityException e) { + // exception will occur if app doesn't have GET_ACCOUNTS permission + return ""; + } + } +} diff --git a/WordPressUtils/src/main/java/org/wordpress/android/util/Version.java b/WordPressUtils/src/main/java/org/wordpress/android/util/Version.java new file mode 100644 index 000000000000..6e695db454da --- /dev/null +++ b/WordPressUtils/src/main/java/org/wordpress/android/util/Version.java @@ -0,0 +1,47 @@ +package org.wordpress.android.util; + +//See: http://stackoverflow.com/a/11024200 +public class Version implements Comparable { + private String version; + + public final String get() { + return this.version; + } + + public Version(String version) { + if(version == null) + throw new IllegalArgumentException("Version can not be null"); + if(!version.matches("[0-9]+(\\.[0-9]+)*")) + throw new IllegalArgumentException("Invalid version format"); + this.version = version; + } + + @Override public int compareTo(Version that) { + if(that == null) + return 1; + String[] thisParts = this.get().split("\\."); + String[] thatParts = that.get().split("\\."); + int length = Math.max(thisParts.length, thatParts.length); + for(int i = 0; i < length; i++) { + int thisPart = i < thisParts.length ? + Integer.parseInt(thisParts[i]) : 0; + int thatPart = i < thatParts.length ? + Integer.parseInt(thatParts[i]) : 0; + if(thisPart < thatPart) + return -1; + if(thisPart > thatPart) + return 1; + } + return 0; + } + + @Override public boolean equals(Object that) { + if(this == that) + return true; + if(that == null) + return false; + if(this.getClass() != that.getClass()) + return false; + return this.compareTo((Version) that) == 0; + } +} \ No newline at end of file diff --git a/WordPressUtils/src/main/java/org/wordpress/android/util/WPHtmlTagHandler.java b/WordPressUtils/src/main/java/org/wordpress/android/util/WPHtmlTagHandler.java new file mode 100644 index 000000000000..fa96a998a23c --- /dev/null +++ b/WordPressUtils/src/main/java/org/wordpress/android/util/WPHtmlTagHandler.java @@ -0,0 +1,59 @@ +package org.wordpress.android.util; + +import android.text.Editable; +import android.text.Html; +import android.text.style.BulletSpan; +import android.text.style.LeadingMarginSpan; + +import org.xml.sax.XMLReader; + +import java.util.Vector; + +/** + * Handle tags that the Html class doesn't understand + * Tweaked from source at http://stackoverflow.com/questions/4044509/android-how-to-use-the-html-taghandler + */ +public class WPHtmlTagHandler implements Html.TagHandler { + private int mListItemCount = 0; + private Vector mListParents = new Vector(); + + @Override + public void handleTag(final boolean opening, final String tag, Editable output, + final XMLReader xmlReader) { + if (tag.equals("ul") || tag.equals("ol") || tag.equals("dd")) { + if (opening) { + mListParents.add(tag); + } else { + mListParents.remove(tag); + } + mListItemCount = 0; + } else if (tag.equals("li") && !opening) { + handleListTag(output); + } + } + + private void handleListTag(Editable output) { + if (mListParents.lastElement().equals("ul")) { + output.append("\n"); + String[] split = output.toString().split("\n"); + int start = 0; + if (split.length != 1) { + int lastIndex = split.length - 1; + start = output.length() - split[lastIndex].length() - 1; + } + output.setSpan(new BulletSpan(15 * mListParents.size()), start, output.length(), 0); + } else if (mListParents.lastElement().equals("ol")) { + mListItemCount++; + output.append("\n"); + String[] split = output.toString().split("\n"); + int start = 0; + if (split.length != 1) { + int lastIndex = split.length - 1; + start = output.length() - split[lastIndex].length() - 1; + } + output.insert(start, mListItemCount + ". "); + output.setSpan(new LeadingMarginSpan.Standard(15 * mListParents.size()), start, + output.length(), 0); + } + } +} diff --git a/WordPressUtils/src/main/java/org/wordpress/android/util/WPQuoteSpan.java b/WordPressUtils/src/main/java/org/wordpress/android/util/WPQuoteSpan.java new file mode 100644 index 000000000000..37d5dfe6dee2 --- /dev/null +++ b/WordPressUtils/src/main/java/org/wordpress/android/util/WPQuoteSpan.java @@ -0,0 +1,44 @@ +package org.wordpress.android.util; + +import android.graphics.Canvas; +import android.graphics.Paint; +import android.text.Layout; +import android.text.style.QuoteSpan; + +/** + * Customzed QuoteSpan for use in SpannableString's + */ +public class WPQuoteSpan extends QuoteSpan { + public static final int STRIPE_COLOR = 0xFF21759B; + private static final int STRIPE_WIDTH = 5; + private static final int GAP_WIDTH = 20; + + public WPQuoteSpan(){ + super(STRIPE_COLOR); + } + + @Override + public int getLeadingMargin(boolean first) { + int margin = GAP_WIDTH * 2 + STRIPE_WIDTH; + return margin; + } + + /** + * Draw a nice thick gray bar if Ice Cream Sandwhich or newer. There's a + * bug on older devices that does not respect the increased margin. + */ + @Override + public void drawLeadingMargin(Canvas c, Paint p, int x, int dir, int top, int baseline, int bottom, + CharSequence text, int start, int end, boolean first, Layout layout) { + Paint.Style style = p.getStyle(); + int color = p.getColor(); + + p.setStyle(Paint.Style.FILL); + p.setColor(STRIPE_COLOR); + + c.drawRect(GAP_WIDTH + x, top, x + dir * STRIPE_WIDTH, bottom, p); + + p.setStyle(style); + p.setColor(color); + } +} diff --git a/WordPressUtils/src/main/java/org/wordpress/android/util/WPWebChromeClient.java b/WordPressUtils/src/main/java/org/wordpress/android/util/WPWebChromeClient.java new file mode 100644 index 000000000000..6a40c6f3807b --- /dev/null +++ b/WordPressUtils/src/main/java/org/wordpress/android/util/WPWebChromeClient.java @@ -0,0 +1,29 @@ +package org.wordpress.android.util; + +import android.app.Activity; +import android.view.View; +import android.webkit.WebChromeClient; +import android.webkit.WebView; +import android.widget.ProgressBar; + +public class WPWebChromeClient extends WebChromeClient { + private ProgressBar mProgressBar; + private Activity mActivity; + + public WPWebChromeClient(Activity activity, ProgressBar progressBar) { + mProgressBar = progressBar; + mActivity = activity; + } + + public void onProgressChanged(WebView webView, int progress) { + if (!mActivity.isFinishing()) { + mActivity.setTitle(webView.getTitle()); + } + if (progress == 100) { + mProgressBar.setVisibility(View.GONE); + } else { + mProgressBar.setVisibility(View.VISIBLE); + mProgressBar.setProgress(progress); + } + } +} \ No newline at end of file From fddb5a092adf1e792cf1a397ad82cb0c390ea928 Mon Sep 17 00:00:00 2001 From: Beau Collins Date: Tue, 1 Jul 2014 12:48:09 -0400 Subject: [PATCH 05/31] Ignoring iml files --- .gitignore | 3 +- WordPressUtils/WordPressUtils.iml | 69 ------------------------------- 2 files changed, 2 insertions(+), 70 deletions(-) delete mode 100644 WordPressUtils/WordPressUtils.iml diff --git a/.gitignore b/.gitignore index b40b05459907..c0880e009aff 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ .gradle/ -build/* \ No newline at end of file +build/* +*.iml \ No newline at end of file diff --git a/WordPressUtils/WordPressUtils.iml b/WordPressUtils/WordPressUtils.iml deleted file mode 100644 index 86bc5a99c075..000000000000 --- a/WordPressUtils/WordPressUtils.iml +++ /dev/null @@ -1,69 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - From c9e6b5217e351accc7315809bd617d0cade376f2 Mon Sep 17 00:00:00 2001 From: Dan Roundhill Date: Tue, 1 Jul 2014 17:33:38 -0400 Subject: [PATCH 06/31] Restores query separator period in `JSONUtils`. --- .../src/main/java/org/wordpress/android/util/JSONUtil.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/WordPressUtils/src/main/java/org/wordpress/android/util/JSONUtil.java b/WordPressUtils/src/main/java/org/wordpress/android/util/JSONUtil.java index 5e24e96072d4..199fba703db0 100644 --- a/WordPressUtils/src/main/java/org/wordpress/android/util/JSONUtil.java +++ b/WordPressUtils/src/main/java/org/wordpress/android/util/JSONUtil.java @@ -10,7 +10,7 @@ import java.util.ArrayList; public class JSONUtil { - private static String QUERY_SEPERATOR= ""; + private static String QUERY_SEPERATOR="."; private static String QUERY_ARRAY_INDEX_START="["; private static String QUERY_ARRAY_INDEX_END="]"; private static String QUERY_ARRAY_FIRST="first"; From 79b7014a4e9674618ddd4a4b7be5fb63b361941c Mon Sep 17 00:00:00 2001 From: Maxime Biais Date: Wed, 2 Jul 2014 12:04:23 -0400 Subject: [PATCH 07/31] gitignore all build/ directories --- .gitignore | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index c0880e009aff..0959e89bf85b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,3 @@ .gradle/ -build/* +build/ *.iml \ No newline at end of file From 32e98a9cbd090b5490ba5e9155953de82018fc99 Mon Sep 17 00:00:00 2001 From: Maxime Biais Date: Wed, 9 Jul 2014 10:21:42 +0200 Subject: [PATCH 08/31] merge from WordPress-Android@1a74364d0 --- WordPressUtils/build.gradle | 1 - .../org/wordpress/android/util/HtmlUtils.java | 24 +++++++++++++++---- 2 files changed, 19 insertions(+), 6 deletions(-) diff --git a/WordPressUtils/build.gradle b/WordPressUtils/build.gradle index 88142d1a06a6..621aa68646b3 100644 --- a/WordPressUtils/build.gradle +++ b/WordPressUtils/build.gradle @@ -28,5 +28,4 @@ android { minSdkVersion 14 targetSdkVersion 19 } - } diff --git a/WordPressUtils/src/main/java/org/wordpress/android/util/HtmlUtils.java b/WordPressUtils/src/main/java/org/wordpress/android/util/HtmlUtils.java index 44760b05f99c..4c50a2c37834 100644 --- a/WordPressUtils/src/main/java/org/wordpress/android/util/HtmlUtils.java +++ b/WordPressUtils/src/main/java/org/wordpress/android/util/HtmlUtils.java @@ -4,10 +4,15 @@ import android.content.res.Resources; import android.text.Html; import android.text.SpannableStringBuilder; +import android.text.Spanned; import android.text.TextUtils; +import android.text.style.ForegroundColorSpan; import android.text.style.QuoteSpan; import org.apache.commons.lang.StringEscapeUtils; +import org.wordpress.android.util.AppLog.T; +import org.wordpress.android.util.CrashlyticsUtils.ExceptionType; +import org.wordpress.android.util.CrashlyticsUtils.ExtraKey; public class HtmlUtils { /* @@ -98,7 +103,7 @@ public static String stripScript(final String text) { int end = sb.indexOf("", start); if (end == -1) return sb.toString(); - sb.delete(start, end+9); + sb.delete(start, end + 9); start = sb.indexOf(",
      ,
      tags and replacing Emoticons with Emojis */ - public static SpannableStringBuilder fromHtml(String source) { + public static SpannableStringBuilder fromHtml(String source, WPImageGetter wpImageGetter) { SpannableStringBuilder html; try { - html = (SpannableStringBuilder) Html.fromHtml(source, null, new WPHtmlTagHandler()); + html = (SpannableStringBuilder) Html.fromHtml(source, wpImageGetter, new WPHtmlTagHandler()); } catch (RuntimeException runtimeException) { // In case our tag handler fails - html = (SpannableStringBuilder) Html.fromHtml(source, null, null); + html = (SpannableStringBuilder) Html.fromHtml(source, wpImageGetter, null); + // Log the exception and text that produces the error + CrashlyticsUtils.setString(ExtraKey.NOTE_HTMLDATA, source); + CrashlyticsUtils.logException(runtimeException, ExceptionType.SPECIFIC, T.NOTIFS); } Emoticons.replaceEmoticonsWithEmoji(html); QuoteSpan spans[] = html.getSpans(0, html.length(), QuoteSpan.class); for (QuoteSpan span : spans) { - html.setSpan(new WPQuoteSpan(), html.getSpanStart(span), html.getSpanEnd(span), html.getSpanFlags( + html.setSpan(new WPHtml.WPQuoteSpan(), html.getSpanStart(span), html.getSpanEnd(span), html.getSpanFlags( + span)); + html.setSpan(new ForegroundColorSpan(0xFF666666), html.getSpanStart(span), html.getSpanEnd(span), html.getSpanFlags( span)); html.removeSpan(span); } return html; } + + public static Spanned fromHtml(String source) { + return fromHtml(source, null); + } } From b4baff8c740b53ca2e0098c3eddb83c80944726e Mon Sep 17 00:00:00 2001 From: Maxime Biais Date: Wed, 9 Jul 2014 10:54:46 +0200 Subject: [PATCH 09/31] remove crashlytics reference from Utils --- .../src/main/java/org/wordpress/android/util/HtmlUtils.java | 6 ------ 1 file changed, 6 deletions(-) diff --git a/WordPressUtils/src/main/java/org/wordpress/android/util/HtmlUtils.java b/WordPressUtils/src/main/java/org/wordpress/android/util/HtmlUtils.java index 4c50a2c37834..22c440c4eefc 100644 --- a/WordPressUtils/src/main/java/org/wordpress/android/util/HtmlUtils.java +++ b/WordPressUtils/src/main/java/org/wordpress/android/util/HtmlUtils.java @@ -10,9 +10,6 @@ import android.text.style.QuoteSpan; import org.apache.commons.lang.StringEscapeUtils; -import org.wordpress.android.util.AppLog.T; -import org.wordpress.android.util.CrashlyticsUtils.ExceptionType; -import org.wordpress.android.util.CrashlyticsUtils.ExtraKey; public class HtmlUtils { /* @@ -120,9 +117,6 @@ public static SpannableStringBuilder fromHtml(String source, WPImageGetter wpIma } catch (RuntimeException runtimeException) { // In case our tag handler fails html = (SpannableStringBuilder) Html.fromHtml(source, wpImageGetter, null); - // Log the exception and text that produces the error - CrashlyticsUtils.setString(ExtraKey.NOTE_HTMLDATA, source); - CrashlyticsUtils.logException(runtimeException, ExceptionType.SPECIFIC, T.NOTIFS); } Emoticons.replaceEmoticonsWithEmoji(html); QuoteSpan spans[] = html.getSpans(0, html.length(), QuoteSpan.class); From 7c6d4f4a7e8946175678dd5cf0a5d0660d42007c Mon Sep 17 00:00:00 2001 From: Maxime Biais Date: Wed, 9 Jul 2014 12:40:18 +0200 Subject: [PATCH 10/31] add WPImageGetter and fix HtmlUtils --- WordPressUtils/build.gradle | 1 + .../org/wordpress/android/util/HtmlUtils.java | 22 +- .../wordpress/android/util/WPImageGetter.java | 198 ++++++++++++++++++ gradle/wrapper/gradle-wrapper.properties | 4 +- 4 files changed, 213 insertions(+), 12 deletions(-) create mode 100644 WordPressUtils/src/main/java/org/wordpress/android/util/WPImageGetter.java diff --git a/WordPressUtils/build.gradle b/WordPressUtils/build.gradle index 621aa68646b3..c8e051e15178 100644 --- a/WordPressUtils/build.gradle +++ b/WordPressUtils/build.gradle @@ -14,6 +14,7 @@ repositories { dependencies { compile 'commons-lang:commons-lang:2.6' + compile 'com.mcxiaoke.volley:library:1.0.+' } android { diff --git a/WordPressUtils/src/main/java/org/wordpress/android/util/HtmlUtils.java b/WordPressUtils/src/main/java/org/wordpress/android/util/HtmlUtils.java index 22c440c4eefc..c79fe0ecb079 100644 --- a/WordPressUtils/src/main/java/org/wordpress/android/util/HtmlUtils.java +++ b/WordPressUtils/src/main/java/org/wordpress/android/util/HtmlUtils.java @@ -17,8 +17,9 @@ public class HtmlUtils { * but it's very slow, so avoid using this where performance is important */ public static String stripHtml(final String text) { - if (TextUtils.isEmpty(text)) + if (TextUtils.isEmpty(text)) { return ""; + } return Html.fromHtml(text).toString().trim(); } @@ -55,8 +56,7 @@ private static String trimStart(final String str) { return ""; } int start = 0; - while (start != strLen - && (Character.isWhitespace(str.charAt(start)) || str.charAt(start) == 160)) { + while (start != strLen && (Character.isWhitespace(str.charAt(start)) || str.charAt(start) == 160)) { start++; } return str.substring(start); @@ -66,8 +66,9 @@ private static String trimStart(final String str) { * convert html entities to actual Unicode characters - relies on commons apache lang */ public static String fastUnescapeHtml(final String text) { - if (text == null || !text.contains("&")) + if (text == null || !text.contains("&")) { return text; + } return StringEscapeUtils.unescapeHtml(text); } @@ -90,16 +91,18 @@ public static String colorResToHtmlColor(Context context, int resId) { * first seen at http://houseofgeekery.com/2013/11/03/13-terrible-x-men-we-wont-see-in-the-movies/ */ public static String stripScript(final String text) { - if (text == null) + if (text == null) { return null; + } StringBuilder sb = new StringBuilder(text); int start = sb.indexOf(" -1) { int end = sb.indexOf("", start); - if (end == -1) + if (end == -1) { return sb.toString(); + } sb.delete(start, end + 9); start = sb.indexOf(" mWeakView; + private int mMaxSize; + private ImageLoader mImageLoader; + private Drawable mLoadingDrawable; + private Drawable mFailedDrawable; + + public WPImageGetter(TextView view) { + this(view, 0); + } + + public WPImageGetter(TextView view, int maxSize) { + mWeakView = new WeakReference(view); + mMaxSize = maxSize; + } + + public WPImageGetter(TextView view, int maxSize, ImageLoader imageLoader, Drawable loadingDrawable, + Drawable failedDrawable) { + mWeakView = new WeakReference(view); + mMaxSize = maxSize; + mImageLoader = imageLoader; + mLoadingDrawable = loadingDrawable; + mFailedDrawable = failedDrawable; + } + + public void setImageLoader(ImageLoader imageLoader) { + mImageLoader = imageLoader; + } + + public void setLoadingDrawable(Drawable loadingDrawable) { + mLoadingDrawable = loadingDrawable; + } + + public void setFailedDrawable(Drawable failedDrawable) { + mFailedDrawable = failedDrawable; + } + + private TextView getView() { + return mWeakView.get(); + } + + @Override + public Drawable getDrawable(String source) { + if (mImageLoader == null || mLoadingDrawable == null || mFailedDrawable == null) { + throw new RuntimeException("Developer, you need to call setImageLoader, setLoadingDrawable and setFailedDrawable"); + } + + if (TextUtils.isEmpty(source)) { + return null; + } + + // images in reader comments may skip "http:" (no idea why) so make sure to add protocol here + if (source.startsWith("//")) { + source = "http:" + source; + } + + // use Photon if a max size is requested (otherwise the full-sized image will be downloaded + // and then resized) + if (mMaxSize > 0) { + source = PhotonUtils.getPhotonImageUrl(source, mMaxSize, 0); + } + + TextView view = getView(); + // Drawable loading = view.getContext().getResources().getDrawable(R.drawable.remote_image); FIXME: here + // Drawable failed = view.getContext().getResources().getDrawable(R.drawable.remote_failed); + final RemoteDrawable remote = new RemoteDrawable(mLoadingDrawable, mFailedDrawable); + + mImageLoader.get(source, new ImageLoader.ImageListener() { + @Override + public void onErrorResponse(VolleyError error) { + remote.displayFailed(); + TextView view = getView(); + if (view != null) { + view.invalidate(); + } + } + + @Override + public void onResponse(ImageLoader.ImageContainer response, boolean isImmediate) { + if (response.getBitmap() != null) { + // make sure view is still valid + TextView view = getView(); + if (view == null) { + AppLog.w(T.UTILS, "WPImageGetter view is invalid"); + return; + } + + Drawable drawable = new BitmapDrawable(view.getContext().getResources(), response.getBitmap()); + final int oldHeight = remote.getBounds().height(); + int maxWidth = view.getWidth() - view.getPaddingLeft() - view.getPaddingRight(); + if (mMaxSize > 0 && (maxWidth > mMaxSize || maxWidth == 0)) { + maxWidth = mMaxSize; + } + remote.setRemoteDrawable(drawable, maxWidth); + + // image is from cache? don't need to modify view height + if (isImmediate) { + return; + } + + int newHeight = remote.getBounds().height(); + view.invalidate(); + // For ICS + view.setHeight(view.getHeight() + newHeight - oldHeight); + // Pre ICS + view.setEllipsize(null); + } + } + }); + return remote; + } + + private static class RemoteDrawable extends BitmapDrawable { + protected Drawable mRemoteDrawable; + protected Drawable mLoadingDrawable; + protected Drawable mFailedDrawable; + private boolean mDidFail = false; + + public RemoteDrawable(Drawable loadingDrawable, Drawable failedDrawable) { + mLoadingDrawable = loadingDrawable; + mFailedDrawable = failedDrawable; + setBounds(0, 0, mLoadingDrawable.getIntrinsicWidth(), mLoadingDrawable.getIntrinsicHeight()); + } + + public void displayFailed() { + mDidFail = true; + } + + public void setBounds(int x, int y, int width, int height) { + super.setBounds(x, y, width, height); + if (mRemoteDrawable != null) { + mRemoteDrawable.setBounds(x, y, width, height); + return; + } + if (mLoadingDrawable != null) { + mLoadingDrawable.setBounds(x, y, width, height); + mFailedDrawable.setBounds(x, y, width, height); + } + } + + public void setRemoteDrawable(Drawable remote) { + mRemoteDrawable = remote; + setBounds(0, 0, mRemoteDrawable.getIntrinsicWidth(), mRemoteDrawable.getIntrinsicHeight()); + } + + public void setRemoteDrawable(Drawable remote, int maxWidth) { + // null sentinel for now + if (remote == null) { + // throw error + return; + } + mRemoteDrawable = remote; + // determine if we need to scale the image to fit in view + int imgWidth = remote.getIntrinsicWidth(); + int imgHeight = remote.getIntrinsicHeight(); + float xScale = (float) imgWidth / (float) maxWidth; + if (xScale > 1.0f) { + setBounds(0, 0, Math.round(imgWidth / xScale), Math.round(imgHeight / xScale)); + } else { + setBounds(0, 0, imgWidth, imgHeight); + } + } + + public boolean didFail() { + return mDidFail; + } + + public void draw(Canvas canvas) { + if (mRemoteDrawable != null) { + mRemoteDrawable.draw(canvas); + } else if (didFail()) { + mFailedDrawable.draw(canvas); + } else { + mLoadingDrawable.draw(canvas); + } + } + } +} diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 67a8a664b1e5..2bdda831e5af 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ -#Mon Jun 30 12:35:54 EDT 2014 +#Wed Jul 09 11:48:51 CEST 2014 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-1.11-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-1.11-all.zip From cf85e19de1f632641940197d1abe04183b2118d5 Mon Sep 17 00:00:00 2001 From: Maxime Biais Date: Wed, 9 Jul 2014 12:40:42 +0200 Subject: [PATCH 11/31] ignore .idea and local.properties --- .gitignore | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 0959e89bf85b..dbdbfb819f8d 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ .gradle/ build/ -*.iml \ No newline at end of file +*.iml +local.properties +.idea/ From 50f2c80c0136b9d1b1bc62d301ec4d3eaa3180e8 Mon Sep 17 00:00:00 2001 From: Maxime Biais Date: Wed, 9 Jul 2014 16:30:24 +0200 Subject: [PATCH 12/31] maven deploying script --- tools/deploy-mvn-artifact.conf-example | 1 + tools/deploy-mvn-artifact.sh | 19 +++++++++++++++++++ 2 files changed, 20 insertions(+) create mode 100644 tools/deploy-mvn-artifact.conf-example create mode 100755 tools/deploy-mvn-artifact.sh diff --git a/tools/deploy-mvn-artifact.conf-example b/tools/deploy-mvn-artifact.conf-example new file mode 100644 index 000000000000..5aaf644ad243 --- /dev/null +++ b/tools/deploy-mvn-artifact.conf-example @@ -0,0 +1 @@ +LOCAL_GH_PAGES=file:///Users/max/work/automattic/WordPress-Android-gh-pages/ diff --git a/tools/deploy-mvn-artifact.sh b/tools/deploy-mvn-artifact.sh new file mode 100755 index 000000000000..8afd27f68d3a --- /dev/null +++ b/tools/deploy-mvn-artifact.sh @@ -0,0 +1,19 @@ +#!/bin/sh +v + +. tools/deploy-mvn-artifact.conf +PROJECT=WordPressUtils +VERSION=`grep -E 'versionName' $PROJECT/build.gradle \ + | sed s/versionName// \ + | grep -Eo "[a-zA-Z0-9.-]+"` +GROUPID=org.wordpress +ARTIFACTID=wordpress-utils +AARFILE=$PROJECT/build/outputs/aar/WordPressUtils.aar + +# Deploy release build +mvn deploy:deploy-file -Dfile=$AARFILE \ + -Durl=$LOCAL_GH_PAGES -DgroupId=$GROUPID \ + -DartifactId=$ARTIFACTID -Dversion=$VERSION + +echo ======================================== +echo +echo \"$GROUPID:$ARTIFACTID:$VERSION\" deployed \ No newline at end of file From 575579defbaf4eef1c45664e9186fd3a59f5cf82 Mon Sep 17 00:00:00 2001 From: Maxime Biais Date: Wed, 9 Jul 2014 16:33:40 +0200 Subject: [PATCH 13/31] gitignore tools/deploy-mvn-artifact.conf --- .gitignore | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index dbdbfb819f8d..8babf679a8c4 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,25 @@ -.gradle/ +# generated files build/ -*.iml + +# Local configuration file (sdk path, etc) local.properties +tools/deploy-mvn-artifact.conf + +# Intellij project files +*.iml +*.ipr +*.iws .idea/ + +# Gradle +.gradle/ +gradle.properties + +# Idea +.idea/workspace.xml +*.iml + +# OS X +.DS_Store + +# dependencies From 70a91cb8d940007e0ab29512f033ace6b492f331 Mon Sep 17 00:00:00 2001 From: Maxime Biais Date: Thu, 10 Jul 2014 19:45:04 +0200 Subject: [PATCH 14/31] bump version number to 1.0.1 --- WordPressUtils/build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/WordPressUtils/build.gradle b/WordPressUtils/build.gradle index c8e051e15178..e80f62d15528 100644 --- a/WordPressUtils/build.gradle +++ b/WordPressUtils/build.gradle @@ -24,7 +24,7 @@ android { defaultConfig { applicationId "org.wordpress.android.util" - versionName "1.0.0" + versionName "1.0.1" versionCode 1 minSdkVersion 14 targetSdkVersion 19 From 5d683f67a49af6e079777409bd9a644a053786cb Mon Sep 17 00:00:00 2001 From: Maxime Biais Date: Fri, 11 Jul 2014 17:29:51 +0200 Subject: [PATCH 15/31] use maven plugin to deploy maven artifact --- WordPressUtils/build.gradle | 17 +++++++++++++++-- WordPressUtils/gradle.properties-example | 1 + tools/deploy-mvn-artifact.conf-example | 1 - tools/deploy-mvn-artifact.sh | 19 ------------------- 4 files changed, 16 insertions(+), 22 deletions(-) create mode 100644 WordPressUtils/gradle.properties-example delete mode 100644 tools/deploy-mvn-artifact.conf-example delete mode 100755 tools/deploy-mvn-artifact.sh diff --git a/WordPressUtils/build.gradle b/WordPressUtils/build.gradle index e80f62d15528..bd498a16729b 100644 --- a/WordPressUtils/build.gradle +++ b/WordPressUtils/build.gradle @@ -3,10 +3,13 @@ buildscript { repositories { mavenCentral() } - dependencies { classpath 'com.android.tools.build:gradle:0.12.+' } + dependencies { + classpath 'com.android.tools.build:gradle:0.12.+' + } } apply plugin: 'com.android.library' +apply plugin: 'maven' repositories { mavenCentral() @@ -18,7 +21,6 @@ dependencies { } android { - compileSdkVersion 19 buildToolsVersion "19.1.0" @@ -30,3 +32,14 @@ android { targetSdkVersion 19 } } + +uploadArchives { + repositories { + mavenDeployer { + repository(url: project.repository) + pom.version = android.defaultConfig.versionName + pom.groupId = "org.wordpress" + pom.artifactId = "wordpress-utils" + } + } +} diff --git a/WordPressUtils/gradle.properties-example b/WordPressUtils/gradle.properties-example new file mode 100644 index 000000000000..36ceb8db22bc --- /dev/null +++ b/WordPressUtils/gradle.properties-example @@ -0,0 +1 @@ +repository=file:///Users/max/work/automattic/WordPress-Android-gh-pages/ diff --git a/tools/deploy-mvn-artifact.conf-example b/tools/deploy-mvn-artifact.conf-example deleted file mode 100644 index 5aaf644ad243..000000000000 --- a/tools/deploy-mvn-artifact.conf-example +++ /dev/null @@ -1 +0,0 @@ -LOCAL_GH_PAGES=file:///Users/max/work/automattic/WordPress-Android-gh-pages/ diff --git a/tools/deploy-mvn-artifact.sh b/tools/deploy-mvn-artifact.sh deleted file mode 100755 index 8afd27f68d3a..000000000000 --- a/tools/deploy-mvn-artifact.sh +++ /dev/null @@ -1,19 +0,0 @@ -#!/bin/sh +v - -. tools/deploy-mvn-artifact.conf -PROJECT=WordPressUtils -VERSION=`grep -E 'versionName' $PROJECT/build.gradle \ - | sed s/versionName// \ - | grep -Eo "[a-zA-Z0-9.-]+"` -GROUPID=org.wordpress -ARTIFACTID=wordpress-utils -AARFILE=$PROJECT/build/outputs/aar/WordPressUtils.aar - -# Deploy release build -mvn deploy:deploy-file -Dfile=$AARFILE \ - -Durl=$LOCAL_GH_PAGES -DgroupId=$GROUPID \ - -DartifactId=$ARTIFACTID -Dversion=$VERSION - -echo ======================================== -echo -echo \"$GROUPID:$ARTIFACTID:$VERSION\" deployed \ No newline at end of file From d2a76e2814a2db5c352183fdb94be8e9f4c025e7 Mon Sep 17 00:00:00 2001 From: Maxime Biais Date: Sun, 13 Jul 2014 11:01:52 +0200 Subject: [PATCH 16/31] avoid build failure when gradle.properties file is missing --- WordPressUtils/build.gradle | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/WordPressUtils/build.gradle b/WordPressUtils/build.gradle index bd498a16729b..139cc9d6150d 100644 --- a/WordPressUtils/build.gradle +++ b/WordPressUtils/build.gradle @@ -36,7 +36,11 @@ android { uploadArchives { repositories { mavenDeployer { - repository(url: project.repository) + def repo_url = "" + if (project.hasProperty("repository")) { + repo_url = project.repository + } + repository(url: repo_url) pom.version = android.defaultConfig.versionName pom.groupId = "org.wordpress" pom.artifactId = "wordpress-utils" From d68fb8782371e48416a174db9560e918a2056b4c Mon Sep 17 00:00:00 2001 From: Maxime Biais Date: Wed, 16 Jul 2014 17:31:33 +0200 Subject: [PATCH 17/31] add getCircularBitmap and getRoundedEdgeBitmap from the main project merge in 80014b72ef71cbc2e6b93d89687d5a7681c39c4f --- .../wordpress/android/util/ImageUtils.java | 61 +++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/WordPressUtils/src/main/java/org/wordpress/android/util/ImageUtils.java b/WordPressUtils/src/main/java/org/wordpress/android/util/ImageUtils.java index 1435d52565d4..31dadc911124 100644 --- a/WordPressUtils/src/main/java/org/wordpress/android/util/ImageUtils.java +++ b/WordPressUtils/src/main/java/org/wordpress/android/util/ImageUtils.java @@ -5,7 +5,14 @@ import android.database.Cursor; import android.graphics.Bitmap; import android.graphics.BitmapFactory; +import android.graphics.Canvas; +import android.graphics.Color; import android.graphics.Matrix; +import android.graphics.Paint; +import android.graphics.PorterDuff; +import android.graphics.PorterDuffXfermode; +import android.graphics.Rect; +import android.graphics.RectF; import android.media.ExifInterface; import android.net.Uri; import android.os.AsyncTask; @@ -490,4 +497,58 @@ public static byte[] createThumbnailFromUri(Context context, return stream.toByteArray(); } + + public static Bitmap getCircularBitmap(final Bitmap bitmap) { + if (bitmap==null) + return null; + + final Bitmap output = Bitmap.createBitmap(bitmap.getWidth(), bitmap.getHeight(), Bitmap.Config.ARGB_8888); + final Canvas canvas = new Canvas(output); + final Paint paint = new Paint(); + final Rect rect = new Rect(0, 0, bitmap.getWidth(), bitmap.getHeight()); + final RectF rectF = new RectF(rect); + + paint.setAntiAlias(true); + canvas.drawARGB(0, 0, 0, 0); + paint.setColor(Color.RED); + canvas.drawOval(rectF, paint); + + paint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.SRC_IN)); + canvas.drawBitmap(bitmap, rect, rect, paint); + + // outline + paint.setStyle(Paint.Style.STROKE); + paint.setStrokeWidth(1f); + paint.setColor(Color.DKGRAY); + canvas.drawOval(rectF, paint); + + return output; + } + + public static Bitmap getRoundedEdgeBitmap(final Bitmap bitmap, int radius) { + if (bitmap == null) { + return null; + } + + final Bitmap output = Bitmap.createBitmap(bitmap.getWidth(), bitmap.getHeight(), Bitmap.Config.ARGB_8888); + final Canvas canvas = new Canvas(output); + final Paint paint = new Paint(); + final Rect rect = new Rect(0, 0, bitmap.getWidth(), bitmap.getHeight()); + final RectF rectF = new RectF(rect); + + paint.setAntiAlias(true); + canvas.drawARGB(0, 0, 0, 0); + paint.setColor(Color.RED); + canvas.drawRoundRect(rectF, radius, radius, paint); + + paint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.SRC_IN)); + canvas.drawBitmap(bitmap, rect, rect, paint); + + paint.setStyle(Paint.Style.STROKE); + paint.setStrokeWidth(1f); + paint.setColor(Color.DKGRAY); + canvas.drawRoundRect(rectF, radius, radius, paint); + + return output; + } } From a0e973c910a5724a4d9a309016aae3538a8f2976 Mon Sep 17 00:00:00 2001 From: Maxime Biais Date: Thu, 17 Jul 2014 11:29:52 +0200 Subject: [PATCH 18/31] document getPrimaryEmail method --- .../src/main/java/org/wordpress/android/util/UserEmail.java | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/WordPressUtils/src/main/java/org/wordpress/android/util/UserEmail.java b/WordPressUtils/src/main/java/org/wordpress/android/util/UserEmail.java index f229d4ae1043..dae02b4f01b3 100644 --- a/WordPressUtils/src/main/java/org/wordpress/android/util/UserEmail.java +++ b/WordPressUtils/src/main/java/org/wordpress/android/util/UserEmail.java @@ -8,6 +8,11 @@ import java.util.regex.Pattern; public class UserEmail { + /** + * Get primary account and return its name if it matches the email address pattern. + * + * @return primary account email address if it can be found or empty string else. + */ public static String getPrimaryEmail(Context context) { try { AccountManager accountManager = AccountManager.get(context); From b302b4407dbf3dcc1cdb3560285b9e4c5915a408 Mon Sep 17 00:00:00 2001 From: Maxime Biais Date: Fri, 18 Jul 2014 11:32:08 +0200 Subject: [PATCH 19/31] Move ToastUtils to Utils --- .../wordpress/android/util/ToastUtils.java | 36 +++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 WordPressUtils/src/main/java/org/wordpress/android/util/ToastUtils.java diff --git a/WordPressUtils/src/main/java/org/wordpress/android/util/ToastUtils.java b/WordPressUtils/src/main/java/org/wordpress/android/util/ToastUtils.java new file mode 100644 index 000000000000..a3ff5e0f3a11 --- /dev/null +++ b/WordPressUtils/src/main/java/org/wordpress/android/util/ToastUtils.java @@ -0,0 +1,36 @@ +package org.wordpress.android.util; + +import android.content.Context; +import android.view.Gravity; +import android.widget.Toast; + +/** + * Provides a simplified way to show toast messages without having to create the toast, set the + * desired gravity, etc. + */ +public class ToastUtils { + public enum Duration {SHORT, LONG} + + private ToastUtils() { + throw new AssertionError(); + } + + public static void showToast(Context context, int stringResId) { + showToast(context, stringResId, Duration.SHORT); + } + + public static void showToast(Context context, int stringResId, Duration duration) { + showToast(context, context.getString(stringResId), duration); + } + + public static void showToast(Context context, String text) { + showToast(context, text, Duration.SHORT); + } + + public static void showToast(Context context, String text, Duration duration) { + Toast toast = Toast.makeText(context, text, + (duration == Duration.SHORT ? Toast.LENGTH_SHORT : Toast.LENGTH_LONG)); + toast.setGravity(Gravity.CENTER, 0, 0); + toast.show(); + } +} From 3e93210f33b9c0986855c2293bbbc5e68a8485ef Mon Sep 17 00:00:00 2001 From: Maxime Biais Date: Fri, 18 Jul 2014 11:34:03 +0200 Subject: [PATCH 20/31] Move pull to refresh to Utils --- WordPressUtils/build.gradle | 4 + .../ptr/PullToRefreshHeaderTransformer.java | 99 ++++++++++++ .../android/util/ptr/PullToRefreshHelper.java | 142 ++++++++++++++++++ .../src/main/res/values/strings.xml | 4 + 4 files changed, 249 insertions(+) create mode 100644 WordPressUtils/src/main/java/org/wordpress/android/util/ptr/PullToRefreshHeaderTransformer.java create mode 100644 WordPressUtils/src/main/java/org/wordpress/android/util/ptr/PullToRefreshHelper.java create mode 100644 WordPressUtils/src/main/res/values/strings.xml diff --git a/WordPressUtils/build.gradle b/WordPressUtils/build.gradle index 139cc9d6150d..edc90e01a16a 100644 --- a/WordPressUtils/build.gradle +++ b/WordPressUtils/build.gradle @@ -13,11 +13,15 @@ apply plugin: 'maven' repositories { mavenCentral() + maven { url 'http://wordpress-mobile.github.io/WordPress-Android' } } dependencies { compile 'commons-lang:commons-lang:2.6' compile 'com.mcxiaoke.volley:library:1.0.+' + compile 'com.github.castorflex.smoothprogressbar:library:0.4.0' + compile 'org.wordpress:pulltorefresh-main:+@aar' // org.wordpress version includes some fixes + compile 'com.android.support:support-v13:19.0.+' } android { diff --git a/WordPressUtils/src/main/java/org/wordpress/android/util/ptr/PullToRefreshHeaderTransformer.java b/WordPressUtils/src/main/java/org/wordpress/android/util/ptr/PullToRefreshHeaderTransformer.java new file mode 100644 index 000000000000..3fec8d91fb8b --- /dev/null +++ b/WordPressUtils/src/main/java/org/wordpress/android/util/ptr/PullToRefreshHeaderTransformer.java @@ -0,0 +1,99 @@ +package org.wordpress.android.util.ptr; + +import android.animation.AnimatorSet; +import android.animation.ObjectAnimator; +import android.app.Activity; +import android.view.View; +import android.view.ViewGroup; +import android.view.animation.Animation; + +import org.wordpress.android.util.R; + +import uk.co.senab.actionbarpulltorefresh.library.DefaultHeaderTransformer; +import uk.co.senab.actionbarpulltorefresh.library.sdk.Compat; + +public class PullToRefreshHeaderTransformer extends DefaultHeaderTransformer { + private View mHeaderView; + private ViewGroup mContentLayout; + private long mAnimationDuration; + private boolean mShowProgressBarOnly; + private Animation mHeaderOutAnimation; + private OnTopScrollChangedListener mOnTopScrollChangedListener; + + public interface OnTopScrollChangedListener { + public void onTopScrollChanged(boolean scrolledOnTop); + } + + public void setShowProgressBarOnly(boolean progressBarOnly) { + mShowProgressBarOnly = progressBarOnly; + } + + @Override + public void onViewCreated(Activity activity, View headerView) { + super.onViewCreated(activity, headerView); + mHeaderView = headerView; + mContentLayout = (ViewGroup) headerView.findViewById(R.id.ptr_content); + mAnimationDuration = activity.getResources().getInteger(android.R.integer.config_shortAnimTime); + } + + @Override + public boolean hideHeaderView() { + mShowProgressBarOnly = false; + return super.hideHeaderView(); + } + + @Override + public boolean showHeaderView() { + // Workaround to avoid this bug https://github.com/chrisbanes/ActionBar-PullToRefresh/issues/265 + // Note, that also remove the alpha animation + resetContentLayoutAlpha(); + + boolean changeVis = mHeaderView.getVisibility() != View.VISIBLE; + mContentLayout.setVisibility(View.VISIBLE); + if (changeVis) { + mHeaderView.setVisibility(View.VISIBLE); + AnimatorSet animSet = new AnimatorSet(); + ObjectAnimator alphaAnim = ObjectAnimator.ofFloat(mHeaderView, "alpha", 0f, 1f); + ObjectAnimator transAnim = ObjectAnimator.ofFloat(mContentLayout, "translationY", + -mContentLayout.getHeight(), 10f); + animSet.playTogether(transAnim, alphaAnim); + animSet.play(alphaAnim); + animSet.setDuration(mAnimationDuration); + animSet.start(); + if (mShowProgressBarOnly) { + mContentLayout.setVisibility(View.INVISIBLE); + } + } + return changeVis; + } + + @Override + public void onPulled(float percentagePulled) { + super.onPulled(percentagePulled); + } + + private void resetContentLayoutAlpha() { + Compat.setAlpha(mContentLayout, 1f); + } + + @Override + public void onReset() { + super.onReset(); + // Reset the Content Layout + if (mContentLayout != null) { + Compat.setAlpha(mContentLayout, 1f); + mContentLayout.setVisibility(View.VISIBLE); + } + } + + @Override + public void onTopScrollChanged(boolean scrolledOnTop) { + if (mOnTopScrollChangedListener != null) { + mOnTopScrollChangedListener.onTopScrollChanged(scrolledOnTop); + } + } + + public void setOnTopScrollChangedListener(OnTopScrollChangedListener listener) { + mOnTopScrollChangedListener = listener; + } +} diff --git a/WordPressUtils/src/main/java/org/wordpress/android/util/ptr/PullToRefreshHelper.java b/WordPressUtils/src/main/java/org/wordpress/android/util/ptr/PullToRefreshHelper.java new file mode 100644 index 000000000000..3c7b4661955d --- /dev/null +++ b/WordPressUtils/src/main/java/org/wordpress/android/util/ptr/PullToRefreshHelper.java @@ -0,0 +1,142 @@ +package org.wordpress.android.util.ptr; + +import android.app.Activity; +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.content.SharedPreferences; +import android.content.SharedPreferences.Editor; +import android.preference.PreferenceManager; +import android.support.v4.content.LocalBroadcastManager; +import android.view.View; + +import org.wordpress.android.util.R; +import org.wordpress.android.util.ToastUtils; +import org.wordpress.android.util.ToastUtils.Duration; + +import java.lang.ref.WeakReference; +import java.util.Arrays; +import java.util.HashSet; +import java.util.Set; + +import uk.co.senab.actionbarpulltorefresh.library.ActionBarPullToRefresh; +import uk.co.senab.actionbarpulltorefresh.library.ActionBarPullToRefresh.SetupWizard; +import uk.co.senab.actionbarpulltorefresh.library.Options; +import uk.co.senab.actionbarpulltorefresh.library.PullToRefreshLayout; +import uk.co.senab.actionbarpulltorefresh.library.listeners.OnRefreshListener; +import uk.co.senab.actionbarpulltorefresh.library.viewdelegates.ViewDelegate; + +public class PullToRefreshHelper implements OnRefreshListener { + public static final String BROADCAST_ACTION_REFRESH_MENU_PRESSED = "REFRESH_MENU_PRESSED"; + private static final String REFRESH_BUTTON_HIT_COUNT = "REFRESH_BUTTON_HIT_COUNT"; + private static final Set TOAST_FREQUENCY = new HashSet(Arrays.asList(1, 5, 10, 20, 40, 80, 160, + 320, 640)); + private PullToRefreshHeaderTransformer mHeaderTransformer; + private PullToRefreshLayout mPullToRefreshLayout; + private RefreshListener mRefreshListener; + private WeakReference mActivityRef; + + public PullToRefreshHelper(Activity activity, PullToRefreshLayout pullToRefreshLayout, RefreshListener listener) { + init(activity, pullToRefreshLayout, listener, null); + } + + public PullToRefreshHelper(Activity activity, PullToRefreshLayout pullToRefreshLayout, RefreshListener listener, + java.lang.Class viewClass) { + init(activity, pullToRefreshLayout, listener, viewClass); + } + + public void init(Activity activity, PullToRefreshLayout pullToRefreshLayout, RefreshListener listener, + java.lang.Class viewClass) { + mActivityRef = new WeakReference(activity); + mRefreshListener = listener; + mPullToRefreshLayout = pullToRefreshLayout; + mHeaderTransformer = new PullToRefreshHeaderTransformer(); + SetupWizard setupWizard = ActionBarPullToRefresh.from(activity).options(Options.create().headerTransformer( + mHeaderTransformer).build()).allChildrenArePullable().listener(this); + if (viewClass != null) { + setupWizard.useViewDelegate(viewClass, new ViewDelegate() { + @Override + public boolean isReadyForPull(View view, float v, float v2) { + return true; + } + } + ); + } + setupWizard.setup(mPullToRefreshLayout); + } + + public void setRefreshing(boolean refreshing) { + mHeaderTransformer.setShowProgressBarOnly(refreshing); + mPullToRefreshLayout.setRefreshing(refreshing); + } + + public boolean isRefreshing() { + return mPullToRefreshLayout.isRefreshing(); + } + + @Override + public void onRefreshStarted(View view) { + mRefreshListener.onRefreshStarted(view); + } + + public interface RefreshListener { + public void onRefreshStarted(View view); + } + + public void setEnabled(boolean enabled) { + mPullToRefreshLayout.setEnabled(enabled); + } + + public void refreshAction() { + Activity activity = mActivityRef.get(); + if (activity == null) { + return; + } + setRefreshing(true); + mRefreshListener.onRefreshStarted(mPullToRefreshLayout); + SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(activity); + int refreshHits = preferences.getInt(REFRESH_BUTTON_HIT_COUNT, 0); + refreshHits += 1; + if (TOAST_FREQUENCY.contains(refreshHits)) { + ToastUtils.showToast(activity, R.string.ptr_tip_message, Duration.LONG); + } + Editor editor = preferences.edit(); + editor.putInt(REFRESH_BUTTON_HIT_COUNT, refreshHits); + editor.commit(); + } + + public void registerReceiver(Context context) { + if (context == null) { + return; + } + IntentFilter filter = new IntentFilter(); + filter.addAction(BROADCAST_ACTION_REFRESH_MENU_PRESSED); + LocalBroadcastManager lbm = LocalBroadcastManager.getInstance(context); + lbm.registerReceiver(mReceiver, filter); + } + + public void unregisterReceiver(Context context) { + if (context == null) { + return; + } + try { + LocalBroadcastManager lbm = LocalBroadcastManager.getInstance(context); + lbm.unregisterReceiver(mReceiver); + } catch (IllegalArgumentException e) { + // exception occurs if receiver already unregistered (safe to ignore) + } + } + + private final BroadcastReceiver mReceiver = new BroadcastReceiver() { + @Override + public void onReceive(Context context, Intent intent) { + if (intent == null || intent.getAction() == null) { + return; + } + if (intent.getAction().equals(BROADCAST_ACTION_REFRESH_MENU_PRESSED)) { + refreshAction(); + } + } + }; +} diff --git a/WordPressUtils/src/main/res/values/strings.xml b/WordPressUtils/src/main/res/values/strings.xml new file mode 100644 index 000000000000..2061ba880c10 --- /dev/null +++ b/WordPressUtils/src/main/res/values/strings.xml @@ -0,0 +1,4 @@ + + + Tip: Pull down to refresh + From c6b0ff92454effbc50c22166ffec1f11c421afa2 Mon Sep 17 00:00:00 2001 From: Maxime Biais Date: Wed, 23 Jul 2014 13:19:10 +0200 Subject: [PATCH 21/31] showToast methods now return a Toast object --- .../org/wordpress/android/util/ToastUtils.java | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/WordPressUtils/src/main/java/org/wordpress/android/util/ToastUtils.java b/WordPressUtils/src/main/java/org/wordpress/android/util/ToastUtils.java index a3ff5e0f3a11..9b99c6ea53e1 100644 --- a/WordPressUtils/src/main/java/org/wordpress/android/util/ToastUtils.java +++ b/WordPressUtils/src/main/java/org/wordpress/android/util/ToastUtils.java @@ -15,22 +15,23 @@ private ToastUtils() { throw new AssertionError(); } - public static void showToast(Context context, int stringResId) { - showToast(context, stringResId, Duration.SHORT); + public static Toast showToast(Context context, int stringResId) { + return showToast(context, stringResId, Duration.SHORT); } - public static void showToast(Context context, int stringResId, Duration duration) { - showToast(context, context.getString(stringResId), duration); + public static Toast showToast(Context context, int stringResId, Duration duration) { + return showToast(context, context.getString(stringResId), duration); } - public static void showToast(Context context, String text) { - showToast(context, text, Duration.SHORT); + public static Toast showToast(Context context, String text) { + return showToast(context, text, Duration.SHORT); } - public static void showToast(Context context, String text, Duration duration) { + public static Toast showToast(Context context, String text, Duration duration) { Toast toast = Toast.makeText(context, text, (duration == Duration.SHORT ? Toast.LENGTH_SHORT : Toast.LENGTH_LONG)); toast.setGravity(Gravity.CENTER, 0, 0); toast.show(); + return toast; } } From cd721027aa6dd823262722011efe0b091b6c6416 Mon Sep 17 00:00:00 2001 From: Danilo Ercoli Date: Wed, 23 Jul 2014 18:01:06 +0200 Subject: [PATCH 22/31] Bump version number --- WordPressUtils/build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/WordPressUtils/build.gradle b/WordPressUtils/build.gradle index edc90e01a16a..3b6fd709edeb 100644 --- a/WordPressUtils/build.gradle +++ b/WordPressUtils/build.gradle @@ -30,7 +30,7 @@ android { defaultConfig { applicationId "org.wordpress.android.util" - versionName "1.0.1" + versionName "1.0.2" versionCode 1 minSdkVersion 14 targetSdkVersion 19 From 5b1f8f15b36ae762fb7479f8c0e679ae0d8000d3 Mon Sep 17 00:00:00 2001 From: Maxime Biais Date: Thu, 24 Jul 2014 17:23:02 +0200 Subject: [PATCH 23/31] remove gradle wrapper --- build.gradle | 3 --- 1 file changed, 3 deletions(-) diff --git a/build.gradle b/build.gradle index 47a0f376a65f..e69de29bb2d1 100644 --- a/build.gradle +++ b/build.gradle @@ -1,3 +0,0 @@ -task wrapper(type: Wrapper) { - gradleVersion = '1.11' -} From 449639d78f9bc643d34a794dacf361f2b31c007e Mon Sep 17 00:00:00 2001 From: Maxime Biais Date: Thu, 24 Jul 2014 18:58:43 +0200 Subject: [PATCH 24/31] set defaultPublishConfig to debug - in case we had source dependencies to this module --- WordPressUtils/build.gradle | 2 ++ 1 file changed, 2 insertions(+) diff --git a/WordPressUtils/build.gradle b/WordPressUtils/build.gradle index 3b6fd709edeb..da68c80de30e 100644 --- a/WordPressUtils/build.gradle +++ b/WordPressUtils/build.gradle @@ -25,6 +25,8 @@ dependencies { } android { + defaultPublishConfig 'debug' + compileSdkVersion 19 buildToolsVersion "19.1.0" From 6c2a946d20608f984fad870c8e5a92333719d5f1 Mon Sep 17 00:00:00 2001 From: Nick Bradbury Date: Fri, 25 Jul 2014 18:26:29 -0400 Subject: [PATCH 25/31] Fixed bug in ReaderTagTable that caused tbl_tag_updates to always overwrite the existing row when updating a date column --- .gitignore | 25 + WordPressUtils/build.gradle | 55 ++ WordPressUtils/gradle.properties-example | 1 + WordPressUtils/src/main/AndroidManifest.xml | 5 + .../org/wordpress/android/util/AlertUtil.java | 101 ++++ .../org/wordpress/android/util/AppLog.java | 214 +++++++ .../org/wordpress/android/util/BlogUtils.java | 25 + .../wordpress/android/util/DeviceUtils.java | 94 +++ .../wordpress/android/util/DisplayUtils.java | 93 +++ .../wordpress/android/util/EditTextUtils.java | 77 +++ .../org/wordpress/android/util/Emoticons.java | 106 ++++ .../wordpress/android/util/FormatUtils.java | 35 ++ .../wordpress/android/util/GeocoderUtils.java | 116 ++++ .../wordpress/android/util/GravatarUtils.java | 22 + .../org/wordpress/android/util/HtmlUtils.java | 138 +++++ .../wordpress/android/util/ImageUtils.java | 554 ++++++++++++++++++ .../org/wordpress/android/util/JSONUtil.java | 236 ++++++++ .../util/ListScrollPositionManager.java | 36 ++ .../android/util/LocationHelper.java | 132 +++++ .../org/wordpress/android/util/MapUtils.java | 79 +++ .../wordpress/android/util/PhotonUtils.java | 96 +++ .../android/util/ProfilingUtils.java | 91 +++ .../java/org/wordpress/android/util/README.md | 1 + .../org/wordpress/android/util/SqlUtils.java | 121 ++++ .../wordpress/android/util/StringUtils.java | 278 +++++++++ .../android/util/SystemServiceFactory.java | 17 + .../util/SystemServiceFactoryAbstract.java | 7 + .../util/SystemServiceFactoryDefault.java | 9 + .../wordpress/android/util/ToastUtils.java | 37 ++ .../org/wordpress/android/util/UrlUtils.java | 165 ++++++ .../org/wordpress/android/util/UserEmail.java | 35 ++ .../org/wordpress/android/util/Version.java | 47 ++ .../android/util/WPHtmlTagHandler.java | 59 ++ .../wordpress/android/util/WPImageGetter.java | 198 +++++++ .../wordpress/android/util/WPQuoteSpan.java | 44 ++ .../android/util/WPWebChromeClient.java | 29 + .../ptr/PullToRefreshHeaderTransformer.java | 99 ++++ .../android/util/ptr/PullToRefreshHelper.java | 142 +++++ .../src/main/res/values/strings.xml | 4 + build.gradle | 0 gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 51348 bytes gradle/wrapper/gradle-wrapper.properties | 6 + gradlew | 164 ++++++ gradlew.bat | 90 +++ settings.gradle | 1 + 45 files changed, 3884 insertions(+) create mode 100644 .gitignore create mode 100644 WordPressUtils/build.gradle create mode 100644 WordPressUtils/gradle.properties-example create mode 100644 WordPressUtils/src/main/AndroidManifest.xml create mode 100644 WordPressUtils/src/main/java/org/wordpress/android/util/AlertUtil.java create mode 100644 WordPressUtils/src/main/java/org/wordpress/android/util/AppLog.java create mode 100644 WordPressUtils/src/main/java/org/wordpress/android/util/BlogUtils.java create mode 100644 WordPressUtils/src/main/java/org/wordpress/android/util/DeviceUtils.java create mode 100644 WordPressUtils/src/main/java/org/wordpress/android/util/DisplayUtils.java create mode 100644 WordPressUtils/src/main/java/org/wordpress/android/util/EditTextUtils.java create mode 100644 WordPressUtils/src/main/java/org/wordpress/android/util/Emoticons.java create mode 100644 WordPressUtils/src/main/java/org/wordpress/android/util/FormatUtils.java create mode 100644 WordPressUtils/src/main/java/org/wordpress/android/util/GeocoderUtils.java create mode 100644 WordPressUtils/src/main/java/org/wordpress/android/util/GravatarUtils.java create mode 100644 WordPressUtils/src/main/java/org/wordpress/android/util/HtmlUtils.java create mode 100644 WordPressUtils/src/main/java/org/wordpress/android/util/ImageUtils.java create mode 100644 WordPressUtils/src/main/java/org/wordpress/android/util/JSONUtil.java create mode 100644 WordPressUtils/src/main/java/org/wordpress/android/util/ListScrollPositionManager.java create mode 100644 WordPressUtils/src/main/java/org/wordpress/android/util/LocationHelper.java create mode 100644 WordPressUtils/src/main/java/org/wordpress/android/util/MapUtils.java create mode 100644 WordPressUtils/src/main/java/org/wordpress/android/util/PhotonUtils.java create mode 100644 WordPressUtils/src/main/java/org/wordpress/android/util/ProfilingUtils.java create mode 100644 WordPressUtils/src/main/java/org/wordpress/android/util/README.md create mode 100644 WordPressUtils/src/main/java/org/wordpress/android/util/SqlUtils.java create mode 100644 WordPressUtils/src/main/java/org/wordpress/android/util/StringUtils.java create mode 100644 WordPressUtils/src/main/java/org/wordpress/android/util/SystemServiceFactory.java create mode 100644 WordPressUtils/src/main/java/org/wordpress/android/util/SystemServiceFactoryAbstract.java create mode 100644 WordPressUtils/src/main/java/org/wordpress/android/util/SystemServiceFactoryDefault.java create mode 100644 WordPressUtils/src/main/java/org/wordpress/android/util/ToastUtils.java create mode 100644 WordPressUtils/src/main/java/org/wordpress/android/util/UrlUtils.java create mode 100644 WordPressUtils/src/main/java/org/wordpress/android/util/UserEmail.java create mode 100644 WordPressUtils/src/main/java/org/wordpress/android/util/Version.java create mode 100644 WordPressUtils/src/main/java/org/wordpress/android/util/WPHtmlTagHandler.java create mode 100644 WordPressUtils/src/main/java/org/wordpress/android/util/WPImageGetter.java create mode 100644 WordPressUtils/src/main/java/org/wordpress/android/util/WPQuoteSpan.java create mode 100644 WordPressUtils/src/main/java/org/wordpress/android/util/WPWebChromeClient.java create mode 100644 WordPressUtils/src/main/java/org/wordpress/android/util/ptr/PullToRefreshHeaderTransformer.java create mode 100644 WordPressUtils/src/main/java/org/wordpress/android/util/ptr/PullToRefreshHelper.java create mode 100644 WordPressUtils/src/main/res/values/strings.xml create mode 100644 build.gradle create mode 100644 gradle/wrapper/gradle-wrapper.jar create mode 100644 gradle/wrapper/gradle-wrapper.properties create mode 100755 gradlew create mode 100644 gradlew.bat create mode 100644 settings.gradle diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000000..8babf679a8c4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,25 @@ +# generated files +build/ + +# Local configuration file (sdk path, etc) +local.properties +tools/deploy-mvn-artifact.conf + +# Intellij project files +*.iml +*.ipr +*.iws +.idea/ + +# Gradle +.gradle/ +gradle.properties + +# Idea +.idea/workspace.xml +*.iml + +# OS X +.DS_Store + +# dependencies diff --git a/WordPressUtils/build.gradle b/WordPressUtils/build.gradle new file mode 100644 index 000000000000..da68c80de30e --- /dev/null +++ b/WordPressUtils/build.gradle @@ -0,0 +1,55 @@ + +buildscript { + repositories { + mavenCentral() + } + dependencies { + classpath 'com.android.tools.build:gradle:0.12.+' + } +} + +apply plugin: 'com.android.library' +apply plugin: 'maven' + +repositories { + mavenCentral() + maven { url 'http://wordpress-mobile.github.io/WordPress-Android' } +} + +dependencies { + compile 'commons-lang:commons-lang:2.6' + compile 'com.mcxiaoke.volley:library:1.0.+' + compile 'com.github.castorflex.smoothprogressbar:library:0.4.0' + compile 'org.wordpress:pulltorefresh-main:+@aar' // org.wordpress version includes some fixes + compile 'com.android.support:support-v13:19.0.+' +} + +android { + defaultPublishConfig 'debug' + + compileSdkVersion 19 + buildToolsVersion "19.1.0" + + defaultConfig { + applicationId "org.wordpress.android.util" + versionName "1.0.2" + versionCode 1 + minSdkVersion 14 + targetSdkVersion 19 + } +} + +uploadArchives { + repositories { + mavenDeployer { + def repo_url = "" + if (project.hasProperty("repository")) { + repo_url = project.repository + } + repository(url: repo_url) + pom.version = android.defaultConfig.versionName + pom.groupId = "org.wordpress" + pom.artifactId = "wordpress-utils" + } + } +} diff --git a/WordPressUtils/gradle.properties-example b/WordPressUtils/gradle.properties-example new file mode 100644 index 000000000000..36ceb8db22bc --- /dev/null +++ b/WordPressUtils/gradle.properties-example @@ -0,0 +1 @@ +repository=file:///Users/max/work/automattic/WordPress-Android-gh-pages/ diff --git a/WordPressUtils/src/main/AndroidManifest.xml b/WordPressUtils/src/main/AndroidManifest.xml new file mode 100644 index 000000000000..4f3bd125a3c5 --- /dev/null +++ b/WordPressUtils/src/main/AndroidManifest.xml @@ -0,0 +1,5 @@ + + + + diff --git a/WordPressUtils/src/main/java/org/wordpress/android/util/AlertUtil.java b/WordPressUtils/src/main/java/org/wordpress/android/util/AlertUtil.java new file mode 100644 index 000000000000..76800de4cd6d --- /dev/null +++ b/WordPressUtils/src/main/java/org/wordpress/android/util/AlertUtil.java @@ -0,0 +1,101 @@ +/* + * Copyright (C) 2011 wordpress.org + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.wordpress.android.util; + +import android.app.AlertDialog; +import android.app.Dialog; +import android.content.Context; +import android.content.DialogInterface; + +public class AlertUtil { + /** + * Show Alert Dialog + * @param context + * @param titleId + * @param messageId + */ + public static void showAlert(Context context, int titleId, int messageId) { + Dialog dlg = new AlertDialog.Builder(context) + .setTitle(titleId) + .setPositiveButton(android.R.string.ok, null) + .setMessage(messageId) + .create(); + + dlg.show(); + } + + /** + * Show Alert Dialog + * @param context + * @param titleId + * @param messageId + */ + public static void showAlert(Context context, int titleId, String message) { + Dialog dlg = new AlertDialog.Builder(context) + .setTitle(titleId) + .setPositiveButton(android.R.string.ok, null) + .setMessage(message) + .create(); + + dlg.show(); + } + + /** + * Show Alert Dialog + * @param context + * @param titleId + * @param messageId + * @param positiveButtontxt + * @param positiveListener + * @param negativeButtontxt + * @param negativeListener + */ + public static void showAlert(Context context, int titleId, int messageId, + CharSequence positiveButtontxt, DialogInterface.OnClickListener positiveListener, + CharSequence negativeButtontxt, DialogInterface.OnClickListener negativeListener) { + Dialog dlg = new AlertDialog.Builder(context) + .setTitle(titleId) + .setPositiveButton(positiveButtontxt, positiveListener) + .setNegativeButton(negativeButtontxt, negativeListener) + .setMessage(messageId) + .setCancelable(false) + .create(); + + dlg.show(); + } + + /** + * Show Alert Dialog + * @param context + * @param titleId + * @param messageId + * @param positiveButtontxt + * @param positiveListener + */ + public static void showAlert(Context context, int titleId, String message, + CharSequence positiveButtontxt, DialogInterface.OnClickListener positiveListener) { + Dialog dlg = new AlertDialog.Builder(context) + .setTitle(titleId) + .setPositiveButton(positiveButtontxt, positiveListener) + .setMessage(message) + .setCancelable(false) + .create(); + + dlg.show(); + } +} + diff --git a/WordPressUtils/src/main/java/org/wordpress/android/util/AppLog.java b/WordPressUtils/src/main/java/org/wordpress/android/util/AppLog.java new file mode 100644 index 000000000000..f2fff1b2ef4a --- /dev/null +++ b/WordPressUtils/src/main/java/org/wordpress/android/util/AppLog.java @@ -0,0 +1,214 @@ +package org.wordpress.android.util; + +import android.content.Context; +import android.text.TextUtils; +import android.util.Log; + +import java.io.PrintWriter; +import java.io.StringWriter; +import java.util.ArrayList; +import java.util.Iterator; +import java.util.NoSuchElementException; + +/** + * simple wrapper for Android log calls, enables recording & displaying log + */ +public class AppLog { + // T for Tag + public enum T {READER, EDITOR, MEDIA, NUX, API, STATS, UTILS, NOTIFS, DB, POSTS, COMMENTS, THEMES, TESTS, PROFILING, SIMPERIUM} + public static final String TAG = "WordPress"; + + private static boolean mEnableRecording = false; + + private AppLog() { + throw new AssertionError(); + } + + /* + * defaults to false, pass true to capture log so it can be displayed by AppLogViewerActivity + */ + public static void enableRecording(boolean enable) { + mEnableRecording = enable; + } + + public static void v(T tag, String message) { + Log.v(TAG + "-" + tag.toString(), message); + addEntry(tag, LogLevel.v, message); + } + + public static void d(T tag, String message) { + Log.d(TAG + "-" + tag.toString(), message); + addEntry(tag, LogLevel.d, message); + } + + public static void i(T tag, String message) { + Log.i(TAG + "-" + tag.toString(), message); + addEntry(tag, LogLevel.i, message); + } + + public static void w(T tag, String message) { + Log.w(TAG + "-" + tag.toString(), message); + addEntry(tag, LogLevel.w, message); + } + + public static void e(T tag, String message) { + Log.e(TAG + "-" + tag.toString(), message); + addEntry(tag, LogLevel.e, message); + } + + public static void e(T tag, String message, Throwable tr) { + Log.e(TAG + "-" + tag.toString(), message, tr); + addEntry(tag, LogLevel.e, message + " - exception: " + tr.getMessage()); + addEntry(tag, LogLevel.e, "StackTrace: " + getHTMLStringStackTrace(tr)); + } + + public static void e(T tag, Throwable tr) { + Log.e(TAG + "-" + tag.toString(), tr.getMessage(), tr); + addEntry(tag, LogLevel.e, tr.getMessage()); + addEntry(tag, LogLevel.e, "StackTrace: " + getHTMLStringStackTrace(tr)); + } + + public static void e(T tag, String volleyErrorMsg, int statusCode) { + if (TextUtils.isEmpty(volleyErrorMsg)) { + return; + } + String logText; + if (statusCode == -1) { + logText = volleyErrorMsg; + } else { + logText = volleyErrorMsg + ", status " + statusCode; + } + Log.e(TAG + "-" + tag.toString(), logText); + addEntry(tag, LogLevel.w, logText); + } + + // -------------------------------------------------------------------------------------------------------- + + private static final int MAX_ENTRIES = 99; + + private enum LogLevel { + v, d, i, w, e; + private String toHtmlColor() { + switch(this) { + case v: + return "grey"; + case i: + return "black"; + case w: + return "purple"; + case e: + return "red"; + case d: + default: + return "teal"; + } + } + } + + private static class LogEntry { + LogLevel logLevel; + String logText; + T logTag; + + private String toHtml() { + StringBuilder sb = new StringBuilder() + .append("") + .append("[") + .append(logTag.name()) + .append("] ") + .append(logLevel.name()) + .append(": ") + .append(logText) + .append(""); + return sb.toString(); + } + } + + private static class LogEntryList extends ArrayList { + private synchronized boolean addEntry(LogEntry entry) { + if (size() >= MAX_ENTRIES) + removeFirstEntry(); + return add(entry); + } + private void removeFirstEntry() { + Iterator it = iterator(); + if (!it.hasNext()) + return; + try { + remove(it.next()); + } catch (NoSuchElementException e) { + // ignore + } + } + } + + private static LogEntryList mLogEntries = new LogEntryList(); + + private static void addEntry(T tag, LogLevel level, String text) { + // skip if recording is disabled (default) + if (!mEnableRecording) + return; + LogEntry entry = new LogEntry(); + entry.logLevel = level; + entry.logText = text; + entry.logTag = tag; + mLogEntries.addEntry(entry); + } + + private static String getStringStackTrace(Throwable throwable) { + StringWriter errors = new StringWriter(); + throwable.printStackTrace(new PrintWriter(errors)); + return errors.toString(); + } + + private static String getHTMLStringStackTrace(Throwable throwable) { + return getStringStackTrace(throwable).replace("\n", "
      "); + } + + /* + * returns entire log as html for display (see AppLogViewerActivity) + */ + public static String toHtml(Context context) { + StringBuilder sb = new StringBuilder(); + + // add version & device info + sb.append("WordPress Android version: " + ProfilingUtils.getVersionName(context)).append("
      ") + .append("Android device name: " + DeviceUtils.getInstance().getDeviceName(context)).append("
      "); + + Iterator it = mLogEntries.iterator(); + int lineNum = 1; + while (it.hasNext()) { + sb.append("") + .append(String.format("%02d", lineNum)) + .append(" ") + .append(it.next().toHtml()) + .append("
      "); + lineNum++; + } + return sb.toString(); + } + + + /* + * returns entire log as plain text + */ + public static String toPlainText(Context context) { + StringBuilder sb = new StringBuilder(); + + // add version & device info + sb.append("WordPress Android version: " + ProfilingUtils.getVersionName(context)).append("\n") + .append("Android device name: " + DeviceUtils.getInstance().getDeviceName(context)).append("\n\n"); + + Iterator it = mLogEntries.iterator(); + int lineNum = 1; + while (it.hasNext()) { + sb.append(String.format("%02d - ", lineNum)) + .append(it.next().logText) + .append("\n"); + lineNum++; + } + return sb.toString(); + } +} diff --git a/WordPressUtils/src/main/java/org/wordpress/android/util/BlogUtils.java b/WordPressUtils/src/main/java/org/wordpress/android/util/BlogUtils.java new file mode 100644 index 000000000000..166085a4f066 --- /dev/null +++ b/WordPressUtils/src/main/java/org/wordpress/android/util/BlogUtils.java @@ -0,0 +1,25 @@ +package org.wordpress.android.util; + +import java.util.Comparator; +import java.util.Map; + +public class BlogUtils { + public static Comparator BlogNameComparator = new Comparator() { + public int compare(Object blog1, Object blog2) { + Map blogMap1 = (Map) blog1; + Map blogMap2 = (Map) blog2; + + String blogName1 = MapUtils.getMapStr(blogMap1, "blogName"); + if (blogName1.length() == 0) { + blogName1 = MapUtils.getMapStr(blogMap1, "url"); + } + + String blogName2 = MapUtils.getMapStr(blogMap2, "blogName"); + if (blogName2.length() == 0) { + blogName2 = MapUtils.getMapStr(blogMap2, "url"); + } + + return blogName1.compareToIgnoreCase(blogName2); + } + }; +} \ No newline at end of file diff --git a/WordPressUtils/src/main/java/org/wordpress/android/util/DeviceUtils.java b/WordPressUtils/src/main/java/org/wordpress/android/util/DeviceUtils.java new file mode 100644 index 000000000000..639d5479c301 --- /dev/null +++ b/WordPressUtils/src/main/java/org/wordpress/android/util/DeviceUtils.java @@ -0,0 +1,94 @@ +package org.wordpress.android.util; + +import android.content.Context; +import android.content.pm.PackageManager; +import android.os.Build; + +import org.wordpress.android.util.AppLog.T; + +import java.io.IOException; +import java.io.InputStream; +import java.util.Properties; + +public class DeviceUtils { + private static DeviceUtils instance; + private boolean isKindleFire = false; + + public boolean isKindleFire() { + return isKindleFire; + } + + public static DeviceUtils getInstance() { + if (instance == null) { + instance = new DeviceUtils(); + } + return instance; + } + + private DeviceUtils() { + isKindleFire = android.os.Build.MODEL.equalsIgnoreCase("kindle fire") ? true: false; + } + + /** + * Checks camera availability recursively based on API level. + * + * TODO: change "android.hardware.camera.front" and "android.hardware.camera.any" to + * {@link PackageManager#FEATURE_CAMERA_FRONT} and {@link PackageManager#FEATURE_CAMERA_ANY}, + * respectively, once they become accessible or minSdk version is incremented. + * + * @param context The context. + * @return Whether camera is available. + */ + public boolean hasCamera(Context context) { + final PackageManager pm = context.getPackageManager(); + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.JELLY_BEAN_MR1) { + return pm.hasSystemFeature(PackageManager.FEATURE_CAMERA) + || pm.hasSystemFeature("android.hardware.camera.front"); + } + + return pm.hasSystemFeature("android.hardware.camera.any"); + } + + public String getDeviceName(Context context) { + String manufacturer = Build.MANUFACTURER; + String undecodedModel = Build.MODEL; + String model = null; + + try { + Properties prop = new Properties(); + InputStream fileStream; + // Read the device name from a precomplied list: + // see http://making.meetup.com/post/29648976176/human-readble-android-device-names + fileStream = context.getAssets().open("android_models.properties"); + prop.load(fileStream); + fileStream.close(); + String decodedModel = prop.getProperty(undecodedModel.replaceAll(" ", "_")); + if (decodedModel != null && !decodedModel.trim().equals("")) { + model = decodedModel; + } + } catch (IOException e) { + AppLog.e(T.UTILS, e.getMessage()); + } + + if (model == null) { //Device model not found in the list + if (undecodedModel.startsWith(manufacturer)) { + model = capitalize(undecodedModel); + } else { + model = capitalize(manufacturer) + " " + undecodedModel; + } + } + return model; + } + + private String capitalize(String s) { + if (s == null || s.length() == 0) { + return ""; + } + char first = s.charAt(0); + if (Character.isUpperCase(first)) { + return s; + } else { + return Character.toUpperCase(first) + s.substring(1); + } + } +} diff --git a/WordPressUtils/src/main/java/org/wordpress/android/util/DisplayUtils.java b/WordPressUtils/src/main/java/org/wordpress/android/util/DisplayUtils.java new file mode 100644 index 000000000000..f64527e9a622 --- /dev/null +++ b/WordPressUtils/src/main/java/org/wordpress/android/util/DisplayUtils.java @@ -0,0 +1,93 @@ +package org.wordpress.android.util; + +import android.content.Context; +import android.content.res.Configuration; +import android.graphics.Point; +import android.util.DisplayMetrics; +import android.util.TypedValue; +import android.view.Display; +import android.view.Window; +import android.view.WindowManager; + +public class DisplayUtils { + private DisplayUtils() { + throw new AssertionError(); + } + + public static boolean isLandscape(Context context) { + if (context == null) + return false; + return context.getResources().getConfiguration().orientation == Configuration.ORIENTATION_LANDSCAPE; + } + + public static boolean isLandscapeTablet(Context context) { + return isLandscape(context) && isTablet(context); + } + + public static Point getDisplayPixelSize(Context context) { + WindowManager wm = (WindowManager) context.getSystemService(Context.WINDOW_SERVICE); + Display display = wm.getDefaultDisplay(); + Point size = new Point(); + display.getSize(size); + return size; + } + + public static int getDisplayPixelWidth(Context context) { + Point size = getDisplayPixelSize(context); + return (size.x); + } + + public static int getDisplayPixelHeight(Context context) { + Point size = getDisplayPixelSize(context); + return (size.y); + } + + public static int dpToPx(Context context, int dp) { + float px = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, dp, context.getResources().getDisplayMetrics()); + return (int) px; + } + + public static int pxToDp(Context context, int px) { + DisplayMetrics displayMetrics = context.getResources().getDisplayMetrics(); + return (int) ((px/displayMetrics.density)+0.5); + } + + public static boolean isTablet(Context context) { + // http://stackoverflow.com/a/8427523/1673548 + if (context == null) + return false; + return (context.getResources().getConfiguration().screenLayout & Configuration.SCREENLAYOUT_SIZE_MASK) >= Configuration.SCREENLAYOUT_SIZE_LARGE; + } + + public static boolean isXLarge(Context context) { + if ((context.getResources().getConfiguration().screenLayout & Configuration.SCREENLAYOUT_SIZE_MASK) == Configuration.SCREENLAYOUT_SIZE_XLARGE) + return true; + return false; + } + + /** + * returns the height of the ActionBar if one is enabled - supports both the native ActionBar + * and ActionBarSherlock - http://stackoverflow.com/a/15476793/1673548 + */ + public static int getActionBarHeight(Context context) { + if (context == null) { + return 0; + } + TypedValue tv = new TypedValue(); + if (context.getTheme() != null + && context.getTheme().resolveAttribute(android.R.attr.actionBarSize, tv, true)) { + return TypedValue.complexToDimensionPixelSize(tv.data, context.getResources().getDisplayMetrics()); + } + + // if we get this far, it's because the device doesn't support an ActionBar, + // so return the standard ActionBar height (48dp) + return dpToPx(context, 48); + } + + /** + * detect when FEATURE_ACTION_BAR_OVERLAY has been set + */ + public static boolean hasActionBarOverlay(Window window) { + return window.hasFeature(Window.FEATURE_ACTION_BAR_OVERLAY); + } +} diff --git a/WordPressUtils/src/main/java/org/wordpress/android/util/EditTextUtils.java b/WordPressUtils/src/main/java/org/wordpress/android/util/EditTextUtils.java new file mode 100644 index 000000000000..64ee67e566a9 --- /dev/null +++ b/WordPressUtils/src/main/java/org/wordpress/android/util/EditTextUtils.java @@ -0,0 +1,77 @@ +package org.wordpress.android.util; + +import android.content.Context; +import android.text.TextUtils; +import android.view.inputmethod.InputMethodManager; +import android.widget.EditText; + +/** + * EditText utils + */ +public class EditTextUtils { + private EditTextUtils() { + throw new AssertionError(); + } + + /** + * returns text string from passed EditText + */ + public static String getText(EditText edit) { + if (edit.getText() == null) { + return ""; + } + return edit.getText().toString(); + } + + /** + * moves caret to end of text + */ + public static void moveToEnd(EditText edit) { + if (edit.getText() == null) { + return; + } + edit.setSelection(edit.getText().toString().length()); + } + + /** + * returns true if nothing has been entered into passed editor + */ + public static boolean isEmpty(EditText edit) { + return TextUtils.isEmpty(getText(edit)); + } + + /** + * hide the soft keyboard for the passed EditText + */ + public static void hideSoftInput(EditText edit) { + if (edit == null) { + return; + } + + InputMethodManager imm = getInputMethodManager(edit); + if (imm != null) { + imm.hideSoftInputFromWindow(edit.getWindowToken(), 0); + } + } + + /** + * show the soft keyboard for the passed EditText + */ + public static void showSoftInput(EditText edit) { + if (edit == null) { + return; + } + + edit.requestFocus(); + + InputMethodManager imm = getInputMethodManager(edit); + if (imm != null) { + imm.showSoftInput(edit, InputMethodManager.SHOW_IMPLICIT); + } + } + + private static InputMethodManager getInputMethodManager(EditText edit) { + Context context = edit.getContext(); + return (InputMethodManager) context.getSystemService(Context.INPUT_METHOD_SERVICE); + } +} diff --git a/WordPressUtils/src/main/java/org/wordpress/android/util/Emoticons.java b/WordPressUtils/src/main/java/org/wordpress/android/util/Emoticons.java new file mode 100644 index 000000000000..5a7566a967cf --- /dev/null +++ b/WordPressUtils/src/main/java/org/wordpress/android/util/Emoticons.java @@ -0,0 +1,106 @@ +package org.wordpress.android.util; + +import android.text.Html; +import android.text.SpannableStringBuilder; +import android.text.Spanned; +import android.text.style.ForegroundColorSpan; +import android.text.style.ImageSpan; +import android.util.SparseArray; + +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +import static android.os.Build.VERSION.SDK_INT; +import static android.os.Build.VERSION_CODES; + +public class Emoticons { + public static final int EMOTICON_COLOR = 0xFF21759B; + private static final boolean HAS_EMOJI = SDK_INT >= VERSION_CODES.JELLY_BEAN; + private static final Map wpSmilies; + public static final SparseArray wpSmiliesCodePointToText; + + static { + Map smilies = new HashMap(); + smilies.put("icon_mrgreen.gif", HAS_EMOJI ? "\uD83D\uDE00" : ":mrgreen:" ); + smilies.put("icon_neutral.gif", HAS_EMOJI ? "\uD83D\uDE14" : ":|" ); + smilies.put("icon_twisted.gif", HAS_EMOJI ? "\uD83D\uDE16" : ":twisted:" ); + smilies.put("icon_arrow.gif", HAS_EMOJI ? "\u27A1" : ":arrow:" ); + smilies.put("icon_eek.gif", HAS_EMOJI ? "\uD83D\uDE32" : "8-O" ); + smilies.put("icon_smile.gif", HAS_EMOJI ? "\uD83D\uDE0A" : ":)" ); + smilies.put("icon_confused.gif", HAS_EMOJI ? "\uD83D\uDE15" : ":?" ); + smilies.put("icon_cool.gif", HAS_EMOJI ? "\uD83D\uDE0A" : "8)" ); + smilies.put("icon_evil.gif", HAS_EMOJI ? "\uD83D\uDE21" : ":evil:" ); + smilies.put("icon_biggrin.gif", HAS_EMOJI ? "\uD83D\uDE03" : ":D" ); + smilies.put("icon_idea.gif", HAS_EMOJI ? "\uD83D\uDCA1" : ":idea:" ); + smilies.put("icon_redface.gif", HAS_EMOJI ? "\uD83D\uDE33" : ":oops:" ); + smilies.put("icon_razz.gif", HAS_EMOJI ? "\uD83D\uDE1D" : ":P" ); + smilies.put("icon_rolleyes.gif", HAS_EMOJI ? "\uD83D\uDE0F" : ":roll:" ); + smilies.put("icon_wink.gif", HAS_EMOJI ? "\uD83D\uDE09" : ";)" ); + smilies.put("icon_cry.gif", HAS_EMOJI ? "\uD83D\uDE22" : ":'(" ); + smilies.put("icon_surprised.gif", HAS_EMOJI ? "\uD83D\uDE32" : ":o" ); + smilies.put("icon_lol.gif", HAS_EMOJI ? "\uD83D\uDE03" : ":lol:" ); + smilies.put("icon_mad.gif", HAS_EMOJI ? "\uD83D\uDE21" : ":x" ); + smilies.put("icon_sad.gif", HAS_EMOJI ? "\uD83D\uDE1E" : ":(" ); + smilies.put("icon_exclaim.gif", HAS_EMOJI ? "\u2757" : ":!:" ); + smilies.put("icon_question.gif", HAS_EMOJI ? "\u2753" : ":?:" ); + + wpSmilies = Collections.unmodifiableMap(smilies); + + wpSmiliesCodePointToText = new SparseArray(20); + wpSmiliesCodePointToText.put(10145, ":arrow:"); + wpSmiliesCodePointToText.put(128161, ":idea:"); + wpSmiliesCodePointToText.put(128512, ":mrgreen:"); + wpSmiliesCodePointToText.put(128515, ":D"); + wpSmiliesCodePointToText.put(128522, ":)"); + wpSmiliesCodePointToText.put(128521, ";)"); + wpSmiliesCodePointToText.put(128532, ":|"); + wpSmiliesCodePointToText.put(128533, ":?"); + wpSmiliesCodePointToText.put(128534, ":twisted:"); + wpSmiliesCodePointToText.put(128542, ":("); + wpSmiliesCodePointToText.put(128545, ":evil:"); + wpSmiliesCodePointToText.put(128546, ":'("); + wpSmiliesCodePointToText.put(128562, ":o"); + wpSmiliesCodePointToText.put(128563, ":oops:"); + wpSmiliesCodePointToText.put(128527, ":roll:"); + wpSmiliesCodePointToText.put(10071, ":!:"); + wpSmiliesCodePointToText.put(10067, ":?:"); + } + + public static String lookupImageSmiley(String url){ + return lookupImageSmiley(url, ""); + } + + public static String lookupImageSmiley(String url, String ifNone){ + String file = url.substring(url.lastIndexOf("/") + 1); + if (wpSmilies.containsKey(file)) { + return wpSmilies.get(file); + } + return ifNone; + } + + public static Spanned replaceEmoticonsWithEmoji(SpannableStringBuilder html){ + ImageSpan imgs[] = html.getSpans(0, html.length(), ImageSpan.class); + for (ImageSpan img : imgs) { + String emoticon = Emoticons.lookupImageSmiley(img.getSource()); + if (!emoticon.equals("")) { + int start = html.getSpanStart(img); + html.replace(start, html.getSpanEnd(img), emoticon); + html.setSpan(new ForegroundColorSpan(EMOTICON_COLOR), start, + start + emoticon.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + html.removeSpan(img); + } + } + return html; + } + + public static String replaceEmoticonsWithEmoji(final String text) { + if (text != null && text.contains("icon_")) { + final SpannableStringBuilder html = (SpannableStringBuilder)replaceEmoticonsWithEmoji((SpannableStringBuilder) Html.fromHtml(text)); + // Html.toHtml() is used here rather than toString() since the latter strips html + return Html.toHtml(html); + } else { + return text; + } + } +} \ No newline at end of file diff --git a/WordPressUtils/src/main/java/org/wordpress/android/util/FormatUtils.java b/WordPressUtils/src/main/java/org/wordpress/android/util/FormatUtils.java new file mode 100644 index 000000000000..28282ed5fadf --- /dev/null +++ b/WordPressUtils/src/main/java/org/wordpress/android/util/FormatUtils.java @@ -0,0 +1,35 @@ +package org.wordpress.android.util; + +import java.text.DecimalFormat; +import java.text.NumberFormat; + +public class FormatUtils { + /* + * NumberFormat isn't synchronized, so a separate instance must be created for each thread + * http://developer.android.com/reference/java/text/NumberFormat.html + */ + private static final ThreadLocal IntegerInstance = new ThreadLocal() { + @Override + protected NumberFormat initialValue() { + return NumberFormat.getIntegerInstance(); + } + }; + + private static final ThreadLocal DecimalInstance = new ThreadLocal() { + @Override + protected DecimalFormat initialValue() { + return (DecimalFormat) DecimalFormat.getInstance(); + } + }; + + /* + * returns the passed integer formatted with thousands-separators based on the current locale + */ + public static final String formatInt(int value) { + return IntegerInstance.get().format(value).toString(); + } + + public static final String formatDecimal(int value) { + return DecimalInstance.get().format(value).toString(); + } +} diff --git a/WordPressUtils/src/main/java/org/wordpress/android/util/GeocoderUtils.java b/WordPressUtils/src/main/java/org/wordpress/android/util/GeocoderUtils.java new file mode 100644 index 000000000000..e861a88b8a88 --- /dev/null +++ b/WordPressUtils/src/main/java/org/wordpress/android/util/GeocoderUtils.java @@ -0,0 +1,116 @@ +package org.wordpress.android.util; + +import android.content.Context; +import android.location.Address; +import android.location.Geocoder; + +import java.io.IOException; +import java.util.List; +import java.util.Locale; + +public final class GeocoderUtils { + private GeocoderUtils() { + throw new AssertionError(); + } + + public static Geocoder getGeocoder(Context context) { + // first make sure a Geocoder service exists on this device (requires API 9) + if (!Geocoder.isPresent()) { + return null; + } + + Geocoder gcd; + + try { + gcd = new Geocoder(context, Locale.getDefault()); + } catch (NullPointerException cannotIstantiateEx) { + AppLog.e(AppLog.T.UTILS, "Cannot instantiate Geocoder", cannotIstantiateEx); + return null; + } + + return gcd; + } + + public static Address getAddressFromCoords(Context context, double latitude, double longitude) { + Address address = null; + List
      addresses = null; + + Geocoder gcd = getGeocoder(context); + + if (gcd == null) { + return null; + } + + try { + addresses = gcd.getFromLocation(latitude, longitude, 1); + } catch (IOException e) { + // may get "Unable to parse response from server" IOException here if Geocoder + // service is hit too frequently + AppLog.e(AppLog.T.UTILS, + "Unable to parse response from server. Is Geocoder service hitting the server too frequently?", + e + ); + } + + // addresses may be null or empty if network isn't connected + if (addresses != null && addresses.size() > 0) { + address = addresses.get(0); + } + + return address; + } + + public static Address getAddressFromLocationName(Context context, String locationName) { + int maxResults = 1; + Address address = null; + List
      addresses = null; + + Geocoder gcd = getGeocoder(context); + + if (gcd == null) { + return null; + } + + try { + addresses = gcd.getFromLocationName(locationName, maxResults); + } catch (IOException e) { + AppLog.e(AppLog.T.UTILS, "Failed to get coordinates from location", e); + } + + // addresses may be null or empty if network isn't connected + if (addresses != null && addresses.size() > 0) { + address = addresses.get(0); + } + + return address; + } + + public static String getLocationNameFromAddress(Address address) { + String locality = "", adminArea = "", country = ""; + if (address.getLocality() != null) { + locality = address.getLocality(); + } + + if (address.getAdminArea() != null) { + adminArea = address.getAdminArea(); + } + + if (address.getCountryName() != null) { + country = address.getCountryName(); + } + + return ((locality.equals("")) ? locality : locality + ", ") + + ((adminArea.equals("")) ? adminArea : adminArea + " ") + country; + } + + public static double[] getCoordsFromAddress(Address address) { + double[] coordinates = new double[2]; + + if (address.hasLatitude() && address.hasLongitude()) { + coordinates[0] = address.getLatitude(); + coordinates[1] = address.getLongitude(); + } + + return coordinates; + } +} diff --git a/WordPressUtils/src/main/java/org/wordpress/android/util/GravatarUtils.java b/WordPressUtils/src/main/java/org/wordpress/android/util/GravatarUtils.java new file mode 100644 index 000000000000..c10ce69c81e8 --- /dev/null +++ b/WordPressUtils/src/main/java/org/wordpress/android/util/GravatarUtils.java @@ -0,0 +1,22 @@ +package org.wordpress.android.util; + +import android.text.TextUtils; + +public class GravatarUtils { + /* + * see https://en.gravatar.com/site/implement/images/ + */ + public static String gravatarUrlFromEmail(final String email, int size) { + if (TextUtils.isEmpty(email)) + return ""; + + String url = "http://gravatar.com/avatar/" + + StringUtils.getMd5Hash(email) + + "?d=mm"; + + if (size > 0) + url += "&s=" + Integer.toString(size); + + return url; + } +} diff --git a/WordPressUtils/src/main/java/org/wordpress/android/util/HtmlUtils.java b/WordPressUtils/src/main/java/org/wordpress/android/util/HtmlUtils.java new file mode 100644 index 000000000000..c79fe0ecb079 --- /dev/null +++ b/WordPressUtils/src/main/java/org/wordpress/android/util/HtmlUtils.java @@ -0,0 +1,138 @@ +package org.wordpress.android.util; + +import android.content.Context; +import android.content.res.Resources; +import android.text.Html; +import android.text.SpannableStringBuilder; +import android.text.Spanned; +import android.text.TextUtils; +import android.text.style.ForegroundColorSpan; +import android.text.style.QuoteSpan; + +import org.apache.commons.lang.StringEscapeUtils; + +public class HtmlUtils { + /* + * removes html from the passed string - relies on Html.fromHtml which handles invalid HTML, + * but it's very slow, so avoid using this where performance is important + */ + public static String stripHtml(final String text) { + if (TextUtils.isEmpty(text)) { + return ""; + } + return Html.fromHtml(text).toString().trim(); + } + + /* + * this is much faster than stripHtml() but should only be used when we know the html is valid + * since the regex will be unpredictable with invalid html + */ + public static String fastStripHtml(String str) { + if (TextUtils.isEmpty(str)) { + return str; + } + + // insert a line break before P tags unless the only one is at the start + if (str.lastIndexOf(" 0) { + str = str.replaceAll("", "\n

      "); + } + + // convert BR tags to line breaks + if (str.contains("", "\n"); + } + + // use regex to strip tags, then convert entities in the result + return trimStart(fastUnescapeHtml(str.replaceAll("<(.|\n)*?>", ""))); + } + + /* + * same as apache.commons.lang.StringUtils.stripStart() but also removes non-breaking + * space (160) chars + */ + private static String trimStart(final String str) { + int strLen; + if (str == null || (strLen = str.length()) == 0) { + return ""; + } + int start = 0; + while (start != strLen && (Character.isWhitespace(str.charAt(start)) || str.charAt(start) == 160)) { + start++; + } + return str.substring(start); + } + + /* + * convert html entities to actual Unicode characters - relies on commons apache lang + */ + public static String fastUnescapeHtml(final String text) { + if (text == null || !text.contains("&")) { + return text; + } + return StringEscapeUtils.unescapeHtml(text); + } + + /* + * converts an R.color.xxx resource to an HTML hex color + */ + public static String colorResToHtmlColor(Context context, int resId) { + try { + return String.format("#%06X", 0xFFFFFF & context.getResources().getColor(resId)); + } catch (Resources.NotFoundException e) { + return "#000000"; + } + } + + /* + * remove blocks from the passed string - added to project after noticing + * comments on posts that use the "Sociable" plugin ( http://wordpress.org/plugins/sociable/ ) + * may have a script block which contains followed by a CDATA section followed by , + * all of which will show up if we don't strip it here (example: http://cl.ly/image/0J0N3z3h1i04 ) + * first seen at http://houseofgeekery.com/2013/11/03/13-terrible-x-men-we-wont-see-in-the-movies/ + */ + public static String stripScript(final String text) { + if (text == null) { + return null; + } + + StringBuilder sb = new StringBuilder(text); + int start = sb.indexOf(" -1) { + int end = sb.indexOf("", start); + if (end == -1) { + return sb.toString(); + } + sb.delete(start, end + 9); + start = sb.indexOf(",

        ,
        tags and replacing Emoticons with Emojis + */ + public static SpannableStringBuilder fromHtml(String source, WPImageGetter wpImageGetter) { + SpannableStringBuilder html; + try { + html = (SpannableStringBuilder) Html.fromHtml(source, wpImageGetter, new WPHtmlTagHandler()); + } catch (RuntimeException runtimeException) { + // In case our tag handler fails + html = (SpannableStringBuilder) Html.fromHtml(source, wpImageGetter, null); + } + Emoticons.replaceEmoticonsWithEmoji(html); + QuoteSpan spans[] = html.getSpans(0, html.length(), QuoteSpan.class); + for (QuoteSpan span : spans) { + html.setSpan(new WPQuoteSpan(), html.getSpanStart(span), html.getSpanEnd(span), html.getSpanFlags(span)); + html.setSpan(new ForegroundColorSpan(0xFF666666), html.getSpanStart(span), html.getSpanEnd(span), + html.getSpanFlags(span)); + html.removeSpan(span); + } + return html; + } + + public static Spanned fromHtml(String source) { + return fromHtml(source, null); + } +} diff --git a/WordPressUtils/src/main/java/org/wordpress/android/util/ImageUtils.java b/WordPressUtils/src/main/java/org/wordpress/android/util/ImageUtils.java new file mode 100644 index 000000000000..31dadc911124 --- /dev/null +++ b/WordPressUtils/src/main/java/org/wordpress/android/util/ImageUtils.java @@ -0,0 +1,554 @@ +package org.wordpress.android.util; + +import android.content.ContentResolver; +import android.content.Context; +import android.database.Cursor; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.Matrix; +import android.graphics.Paint; +import android.graphics.PorterDuff; +import android.graphics.PorterDuffXfermode; +import android.graphics.Rect; +import android.graphics.RectF; +import android.media.ExifInterface; +import android.net.Uri; +import android.os.AsyncTask; +import android.provider.MediaStore; +import android.text.TextUtils; +import android.widget.ImageView; + +import org.apache.http.HttpEntity; +import org.apache.http.HttpResponse; +import org.apache.http.HttpStatus; +import org.apache.http.client.methods.HttpGet; +import org.apache.http.impl.client.DefaultHttpClient; + +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.lang.ref.WeakReference; + +public class ImageUtils { + public static int[] getImageSize(Uri uri, Context context){ + String path = null; + BitmapFactory.Options options = new BitmapFactory.Options(); + options.inJustDecodeBounds = true; + + if (uri.toString().contains("content:")) { + String[] projection = new String[] { MediaStore.Images.Media._ID, MediaStore.Images.Media.DATA }; + Cursor cur = context.getContentResolver().query(uri, projection, null, null, null); + if (cur != null) { + if (cur.moveToFirst()) { + int dataColumn = cur.getColumnIndex(MediaStore.Images.Media.DATA); + path = cur.getString(dataColumn); + } + cur.close(); + } + } + + if (TextUtils.isEmpty(path)) { + //The file isn't ContentResolver, or it can't be access by ContentResolver. Try to access the file directly. + path = uri.toString().replace("content://media", ""); + path = path.replace("file://", ""); + } + + BitmapFactory.decodeFile(path, options); + int imageHeight = options.outHeight; + int imageWidth = options.outWidth; + return new int[]{imageWidth, imageHeight}; + } + + // Read the orientation from ContentResolver. If it fails, read from EXIF. + public static int getImageOrientation(Context ctx, String filePath) { + Uri curStream; + int orientation = 0; + + // Remove file protocol + filePath = filePath.replace("file://", ""); + + if (!filePath.contains("content://")) + curStream = Uri.parse("content://media" + filePath); + else + curStream = Uri.parse(filePath); + + try { + Cursor cur = ctx.getContentResolver().query(curStream, new String[]{MediaStore.Images.Media.ORIENTATION}, null, null, null); + if (cur != null) { + if (cur.moveToFirst()) { + orientation = cur.getInt(cur.getColumnIndex(MediaStore.Images.Media.ORIENTATION)); + } + cur.close(); + } + } catch (Exception errReadingContentResolver) { + AppLog.e(AppLog.T.UTILS, errReadingContentResolver); + } + + if (orientation == 0) { + orientation = getExifOrientation(filePath); + } + + return orientation; + } + + + public static int getExifOrientation(String path) { + ExifInterface exif; + try { + exif = new ExifInterface(path); + } catch (IOException e) { + AppLog.e(AppLog.T.UTILS, e); + return 0; + } + + int exifOrientation = exif.getAttributeInt(ExifInterface.TAG_ORIENTATION, 0); + + switch (exifOrientation) { + case ExifInterface.ORIENTATION_NORMAL: + return 0; + case ExifInterface.ORIENTATION_ROTATE_90: + return 90; + case ExifInterface.ORIENTATION_ROTATE_180: + return 180; + case ExifInterface.ORIENTATION_ROTATE_270: + return 270; + default: + return 0; + } + } + + public static Bitmap downloadBitmap(String url) { + final DefaultHttpClient client = new DefaultHttpClient(); + + final HttpGet getRequest = new HttpGet(url); + + try { + HttpResponse response = client.execute(getRequest); + final int statusCode = response.getStatusLine().getStatusCode(); + if (statusCode != HttpStatus.SC_OK) { + AppLog.w(AppLog.T.UTILS, "ImageDownloader Error " + statusCode + + " while retrieving bitmap from " + url); + return null; + } + + final HttpEntity entity = response.getEntity(); + if (entity != null) { + InputStream inputStream = null; + try { + inputStream = entity.getContent(); + return BitmapFactory.decodeStream(inputStream); + } finally { + if (inputStream != null) { + inputStream.close(); + } + entity.consumeContent(); + } + } + } catch (Exception e) { + // Could provide a more explicit error message for IOException or + // IllegalStateException + getRequest.abort(); + AppLog.w(AppLog.T.UTILS, "ImageDownloader Error while retrieving bitmap from " + url); + } + return null; + } + + /** From http://developer.android.com/training/displaying-bitmaps/load-bitmap.html **/ + public static int calculateInSampleSize(BitmapFactory.Options options, int reqWidth, int reqHeight) { + // Raw height and width of image + final int height = options.outHeight; + final int width = options.outWidth; + int inSampleSize = 1; + + if (height > reqHeight || width > reqWidth) { + // Calculate ratios of height and width to requested height and width + final int heightRatio = Math.round((float) height / (float) reqHeight); + final int widthRatio = Math.round((float) width / (float) reqWidth); + + // Choose the smallest ratio as inSampleSize value, this will guarantee + // a final image with both dimensions larger than or equal to the + // requested height and width. + inSampleSize = heightRatio < widthRatio ? heightRatio : widthRatio; + } + + return inSampleSize; + } + + + public interface BitmapWorkerCallback { + public void onBitmapReady(String filePath, ImageView imageView, Bitmap bitmap); + } + + public static class BitmapWorkerTask extends AsyncTask { + private final WeakReference imageViewReference; + private final BitmapWorkerCallback callback; + private int targetWidth; + private int targetHeight; + private String path; + + public BitmapWorkerTask(ImageView imageView, int width, int height, BitmapWorkerCallback callback) { + // Use a WeakReference to ensure the ImageView can be garbage collected + imageViewReference = new WeakReference(imageView); + this.callback = callback; + targetWidth = width; + targetHeight = height; + } + + // Decode image in background. + @Override + protected Bitmap doInBackground(String... params) { + path = params[0]; + + BitmapFactory.Options bfo = new BitmapFactory.Options(); + bfo.inJustDecodeBounds = true; + BitmapFactory.decodeFile(path, bfo); + + bfo.inSampleSize = calculateInSampleSize(bfo, targetWidth, targetHeight); + bfo.inJustDecodeBounds = false; + + // get proper rotation + int bitmapWidth = 0; + int bitmapHeight = 0; + try { + File f = new File(path); + ExifInterface exif = new ExifInterface(f.getPath()); + int orientation = exif.getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL); + int angle = 0; + if (orientation == ExifInterface.ORIENTATION_NORMAL) { // no need to rotate + return BitmapFactory.decodeFile(path, bfo); + } else if (orientation == ExifInterface.ORIENTATION_ROTATE_90) { + angle = 90; + } else if (orientation == ExifInterface.ORIENTATION_ROTATE_180) { + angle = 180; + } else if (orientation == ExifInterface.ORIENTATION_ROTATE_270) { + angle = 270; + } + + Matrix mat = new Matrix(); + mat.postRotate(angle); + + try { + Bitmap bmp = BitmapFactory.decodeStream(new FileInputStream(f), null, bfo); + if (bmp == null) { + AppLog.e(AppLog.T.UTILS, "can't decode bitmap: " + f.getPath()); + return null; + } + bitmapWidth = bmp.getWidth(); + bitmapHeight = bmp.getHeight(); + return Bitmap.createBitmap(bmp, 0, 0, bmp.getWidth(), bmp.getHeight(), mat, true); + } catch (OutOfMemoryError oom) { + AppLog.e(AppLog.T.UTILS, "OutOfMemoryError Error in setting image: " + oom); + } + } catch (IOException e) { + AppLog.e(AppLog.T.UTILS, "Error in setting image", e); + } + + return null; + } + + // Once complete, see if ImageView is still around and set bitmap. + @Override + protected void onPostExecute(Bitmap bitmap) { + if (imageViewReference == null || bitmap == null) + return; + + final ImageView imageView = imageViewReference.get(); + + if (callback != null) + callback.onBitmapReady(path, imageView, bitmap); + + } + } + + + public static String getTitleForWPImageSpan(Context ctx, String filePath) { + if (filePath == null) + return null; + + Uri curStream; + String title; + + if (!filePath.contains("content://")) + curStream = Uri.parse("content://media" + filePath); + else + curStream = Uri.parse(filePath); + + if (filePath.contains("video")) { + return "Video"; + } else { + String[] projection = new String[] { MediaStore.Images.Thumbnails.DATA }; + + Cursor cur; + try { + cur = ctx.getContentResolver().query(curStream, projection, null, null, null); + } catch (Exception e1) { + AppLog.e(AppLog.T.UTILS, e1); + return null; + } + File jpeg; + if (cur != null) { + String thumbData = ""; + if (cur.moveToFirst()) { + int dataColumn = cur.getColumnIndex(MediaStore.Images.Media.DATA); + thumbData = cur.getString(dataColumn); + } + cur.close(); + if (thumbData == null) { + return null; + } + jpeg = new File(thumbData); + } else { + String path = filePath.toString().replace("file://", ""); + jpeg = new File(path); + } + title = jpeg.getName(); + return title; + } + } + + /** + * Resizes an image to be placed in the Post Content Editor + * + * @return resized bitmap + */ + public static Bitmap getWPImageSpanThumbnailFromFilePath(Context context, String filePath, int targetWidth) { + if (filePath == null || context == null) { + return null; + } + + Uri curUri; + if (!filePath.contains("content://")) { + curUri = Uri.parse("content://media" + filePath); + } else { + curUri = Uri.parse(filePath); + } + + if (filePath.contains("video")) { + // Load the video thumbnail from the MediaStore + int videoId = 0; + try { + videoId = Integer.parseInt(curUri.getLastPathSegment()); + } catch (NumberFormatException e) { + } + ContentResolver crThumb = context.getContentResolver(); + BitmapFactory.Options options = new BitmapFactory.Options(); + options.inSampleSize = 1; + Bitmap videoThumbnail = MediaStore.Video.Thumbnails.getThumbnail(crThumb, videoId, MediaStore.Video.Thumbnails.MINI_KIND, + options); + if (videoThumbnail != null) { + return getScaledBitmapAtLongestSide(videoThumbnail, targetWidth); + } else { + return null; + } + } else { + // Create resized bitmap + int rotation = getImageOrientation(context, filePath); + byte[] bytes = createThumbnailFromUri(context, curUri, targetWidth, null, rotation); + + if (bytes != null && bytes.length > 0) { + try { + Bitmap resizedBitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.length); + if (resizedBitmap != null) { + return getScaledBitmapAtLongestSide(resizedBitmap, targetWidth); + } + } catch (OutOfMemoryError e) { + AppLog.e(AppLog.T.UTILS, "OutOfMemoryError Error in setting image: " + e); + return null; + } + } + } + + return null; + } + + /* + Resize a bitmap to the targetSize on its longest side. + */ + public static Bitmap getScaledBitmapAtLongestSide(Bitmap bitmap, int targetSize) { + if (bitmap.getWidth() <= targetSize && bitmap.getHeight() <= targetSize) { + // Do not resize. + return bitmap; + } + + int targetWidth, targetHeight; + if (bitmap.getHeight() > bitmap.getWidth()) { + // Resize portrait bitmap + targetHeight = targetSize; + float percentage = (float) targetSize / bitmap.getHeight(); + targetWidth = (int)(bitmap.getWidth() * percentage); + } else { + // Resize landscape or square image + targetWidth = targetSize; + float percentage = (float) targetSize / bitmap.getWidth(); + targetHeight = (int)(bitmap.getHeight() * percentage); + } + + return Bitmap.createScaledBitmap(bitmap, targetWidth, targetHeight, true); + } + + /** + * nbradbury - 21-Feb-2014 - similar to createThumbnail but more efficient since it doesn't + * require passing the full-size image as an array of bytes[] + */ + public static byte[] createThumbnailFromUri(Context context, + Uri imageUri, + int maxWidth, + String fileExtension, + int rotation) { + if (context == null || imageUri == null || maxWidth <= 0) + return null; + + String filePath = null; + if (imageUri.toString().contains("content:")) { + String[] projection = new String[] { MediaStore.Images.Media.DATA }; + Cursor cur = context.getContentResolver().query(imageUri, projection, null, null, null); + if (cur != null) { + if (cur.moveToFirst()) { + int dataColumn = cur.getColumnIndex(MediaStore.Images.Media.DATA); + filePath = cur.getString(dataColumn); + } + cur.close(); + } + } + + if (TextUtils.isEmpty(filePath)) { + //access the file directly + filePath = imageUri.toString().replace("content://media", ""); + filePath = filePath.replace("file://", ""); + } + + // get just the image bounds + BitmapFactory.Options optBounds = new BitmapFactory.Options(); + optBounds.inJustDecodeBounds = true; + + try { + BitmapFactory.decodeFile(filePath, optBounds); + } catch (OutOfMemoryError e) { + AppLog.e(AppLog.T.UTILS, "OutOfMemoryError Error in setting image: " + e); + return null; + } + + // determine correct scale value (should be power of 2) + // http://stackoverflow.com/questions/477572/android-strange-out-of-memory-issue/3549021#3549021 + int scale = 1; + if (maxWidth > 0 && optBounds.outWidth > maxWidth) { + double d = Math.pow(2, (int) Math.round(Math.log(maxWidth / (double) optBounds.outWidth) / Math.log(0.5))); + scale = (int) d; + } + + BitmapFactory.Options optActual = new BitmapFactory.Options(); + optActual.inSampleSize = scale; + + // Get the roughly resized bitmap + Bitmap bmpResized; + try { + bmpResized = BitmapFactory.decodeFile(filePath, optActual); + } catch (OutOfMemoryError e) { + AppLog.e(AppLog.T.UTILS, "OutOfMemoryError Error in setting image: " + e); + return null; + } + + if (bmpResized == null) + return null; + + ByteArrayOutputStream stream = new ByteArrayOutputStream(); + + // Now calculate exact scale in order to resize accurately + float percentage = (float) maxWidth / bmpResized.getWidth(); + float proportionateHeight = bmpResized.getHeight() * percentage; + int finalHeight = (int) Math.rint(proportionateHeight); + + float scaleWidth = ((float) maxWidth) / bmpResized.getWidth(); + float scaleHeight = ((float) finalHeight) / bmpResized.getHeight(); + + float scaleBy = Math.min(scaleWidth, scaleHeight); + + // Resize the bitmap to exact size + Matrix matrix = new Matrix(); + matrix.postScale(scaleBy, scaleBy); + + // apply rotation + if (rotation != 0) { + matrix.setRotate(rotation); + } + + Bitmap.CompressFormat fmt; + if (fileExtension != null && fileExtension.equalsIgnoreCase("png")) { + fmt = Bitmap.CompressFormat.PNG; + } else { + fmt = Bitmap.CompressFormat.JPEG; + } + + final Bitmap bmpRotated; + try { + bmpRotated = Bitmap.createBitmap(bmpResized, 0, 0, bmpResized.getWidth(), bmpResized.getHeight(), matrix, + true); + } catch (OutOfMemoryError e) { + AppLog.e(AppLog.T.UTILS, "OutOfMemoryError Error in setting image: " + e); + return null; + } + bmpRotated.compress(fmt, 100, stream); + bmpResized.recycle(); + bmpRotated.recycle(); + + return stream.toByteArray(); + } + + public static Bitmap getCircularBitmap(final Bitmap bitmap) { + if (bitmap==null) + return null; + + final Bitmap output = Bitmap.createBitmap(bitmap.getWidth(), bitmap.getHeight(), Bitmap.Config.ARGB_8888); + final Canvas canvas = new Canvas(output); + final Paint paint = new Paint(); + final Rect rect = new Rect(0, 0, bitmap.getWidth(), bitmap.getHeight()); + final RectF rectF = new RectF(rect); + + paint.setAntiAlias(true); + canvas.drawARGB(0, 0, 0, 0); + paint.setColor(Color.RED); + canvas.drawOval(rectF, paint); + + paint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.SRC_IN)); + canvas.drawBitmap(bitmap, rect, rect, paint); + + // outline + paint.setStyle(Paint.Style.STROKE); + paint.setStrokeWidth(1f); + paint.setColor(Color.DKGRAY); + canvas.drawOval(rectF, paint); + + return output; + } + + public static Bitmap getRoundedEdgeBitmap(final Bitmap bitmap, int radius) { + if (bitmap == null) { + return null; + } + + final Bitmap output = Bitmap.createBitmap(bitmap.getWidth(), bitmap.getHeight(), Bitmap.Config.ARGB_8888); + final Canvas canvas = new Canvas(output); + final Paint paint = new Paint(); + final Rect rect = new Rect(0, 0, bitmap.getWidth(), bitmap.getHeight()); + final RectF rectF = new RectF(rect); + + paint.setAntiAlias(true); + canvas.drawARGB(0, 0, 0, 0); + paint.setColor(Color.RED); + canvas.drawRoundRect(rectF, radius, radius, paint); + + paint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.SRC_IN)); + canvas.drawBitmap(bitmap, rect, rect, paint); + + paint.setStyle(Paint.Style.STROKE); + paint.setStrokeWidth(1f); + paint.setColor(Color.DKGRAY); + canvas.drawRoundRect(rectF, radius, radius, paint); + + return output; + } +} diff --git a/WordPressUtils/src/main/java/org/wordpress/android/util/JSONUtil.java b/WordPressUtils/src/main/java/org/wordpress/android/util/JSONUtil.java new file mode 100644 index 000000000000..199fba703db0 --- /dev/null +++ b/WordPressUtils/src/main/java/org/wordpress/android/util/JSONUtil.java @@ -0,0 +1,236 @@ +package org.wordpress.android.util; + +import android.text.TextUtils; + +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; +import org.wordpress.android.util.AppLog.T; + +import java.util.ArrayList; + +public class JSONUtil { + private static String QUERY_SEPERATOR="."; + private static String QUERY_ARRAY_INDEX_START="["; + private static String QUERY_ARRAY_INDEX_END="]"; + private static String QUERY_ARRAY_FIRST="first"; + private static String QUERY_ARRAY_LAST="last"; + + private static final String JSON_NULL_STR = "null"; + + private static final String TAG="JSONUtil"; + /** + * Given a JSONObject and a key path (e.g property.child) and a default it will + * traverse the object graph and pull out the desired property + */ + public static U queryJSON(JSONObject source, String query, U defaultObject) { + int nextSeperator = query.indexOf(QUERY_SEPERATOR); + int nextIndexStart = query.indexOf(QUERY_ARRAY_INDEX_START); + if (nextSeperator == -1 && nextIndexStart == -1) { + // last item let's get it + try { + if (!source.has(query)) { + return defaultObject; + } + Object result = source.get(query); + if (result.getClass().isAssignableFrom(defaultObject.getClass())) { + return (U) result; + } else { + return defaultObject; + } + } catch (java.lang.ClassCastException e) { + AppLog.e(T.UTILS, "Unable to cast the object to " + defaultObject.getClass().getName(), e); + return defaultObject; + } catch (JSONException e) { + AppLog.e(T.UTILS, "Unable to get the Key from the input object. Key:" + query, e); + return defaultObject; + } + } + int endQuery; + if (nextSeperator == -1 || nextIndexStart == -1) { + endQuery = Math.max(nextSeperator, nextIndexStart); + } else { + endQuery = Math.min(nextSeperator, nextIndexStart); + } + String nextQuery = query.substring(endQuery); + String key = query.substring(0, endQuery); + try { + if (source == null) { + return defaultObject; + } + if (nextQuery.indexOf(QUERY_SEPERATOR) == 0) { + return queryJSON(source.getJSONObject(key), nextQuery.substring(1), defaultObject); + } else if (nextQuery.indexOf(QUERY_ARRAY_INDEX_START) == 0) { + return queryJSON(source.getJSONArray(key), nextQuery, defaultObject); + } else if (!nextQuery.equals("")) { + return defaultObject; + } + Object result = source.get(key); + if (result.getClass().isAssignableFrom(defaultObject.getClass())) { + return (U) result; + } else { + AppLog.w(T.UTILS, String.format("The returned object type %s is not assignable to the type %s. Using default!", + result.getClass(),defaultObject.getClass())); + return defaultObject; + } + } catch (java.lang.ClassCastException e) { + AppLog.e(T.UTILS, "Unable to cast the object to " + defaultObject.getClass().getName(), e); + return defaultObject; + } catch (JSONException e) { + AppLog.e(T.UTILS, "Unable to get the Key from the input object. Key:" + query, e); + return defaultObject; + } + } + + /** + * Given a JSONArray and a query (e.g. [0].property) it will traverse the array and + * pull out the requested property. + * + * Acceptable indexes include negative numbers to reference items from the end of + * the list as well as "last" and "first" as more explicit references to "0" and "-1" + */ + public static U queryJSON(JSONArray source, String query, U defaultObject){ + // query must start with [ have an index and then have ] + int indexStart = query.indexOf(QUERY_ARRAY_INDEX_START); + int indexEnd = query.indexOf(QUERY_ARRAY_INDEX_END); + if (indexStart == -1 || indexEnd == -1 || indexStart > indexEnd) { + return defaultObject; + } + // get "index" from "[index]" + String indexStr = query.substring(indexStart + 1, indexEnd); + int index; + if (indexStr.equals(QUERY_ARRAY_FIRST)) { + index = 0; + } else if (indexStr.equals(QUERY_ARRAY_LAST)) { + index = -1; + } else { + index = Integer.parseInt(indexStr); + } + if (index < 0) { + index = source.length() + index; + } + // copy remaining query + String remainingQuery = query.substring(indexEnd + 1); + try { + if (remainingQuery.indexOf(QUERY_ARRAY_INDEX_START) == 0) { + return queryJSON(source.getJSONArray(index), remainingQuery, defaultObject); + } else if (remainingQuery.indexOf(QUERY_SEPERATOR) == 0) { + return queryJSON(source.getJSONObject(index), remainingQuery.substring(1), defaultObject); + } else if (!remainingQuery.equals("")) { + // TODO throw an exception since the query isn't valid? + AppLog.w(T.UTILS, String.format("Incorrect query for next object %s", remainingQuery)); + return defaultObject; + } + Object result = source.get(index); + if (result.getClass().isAssignableFrom(defaultObject.getClass())) { + return (U) result; + } else { + AppLog.w(T.UTILS, String.format("The returned object type %s is not assignable to the type %s. Using default!", + result.getClass(),defaultObject.getClass())); + return defaultObject; + } + } catch (java.lang.ClassCastException e) { + AppLog.e(T.UTILS, "Unable to cast the object to "+defaultObject.getClass().getName(), e); + return defaultObject; + } catch (JSONException e) { + AppLog.e(T.UTILS, "Unable to get the Key from the input object. Key:" + query, e); + return defaultObject; + } + } + + /** + * Convert a JSONArray (expected to contain strings) in a string list + */ + public static ArrayList fromJSONArrayToStringList(JSONArray jsonArray) { + ArrayList stringList = new ArrayList(); + for (int i = 0; i < jsonArray.length(); i++) { + try { + stringList.add(jsonArray.getString(i)); + } catch (JSONException e) { + AppLog.e(T.UTILS, e); + } + } + return stringList; + } + + /** + * Convert a string list in a JSONArray + */ + public static JSONArray fromStringListToJSONArray(ArrayList stringList) { + JSONArray jsonArray = new JSONArray(); + if (stringList != null) { + for (int i = 0; i < stringList.size(); i++) { + jsonArray.put(stringList.get(i)); + } + } + return jsonArray; + } + + /* + * wrapper for JSONObject.optString() which handles "null" values + */ + public static String getString(JSONObject json, String name) { + String value = json.optString(name); + // return empty string for "null" + if (JSON_NULL_STR.equals(value)) + return ""; + return value; + } + + /* + * use with strings that contain HTML entities + */ + public static String getStringDecoded(JSONObject json, String name) { + String value = getString(json, name); + return HtmlUtils.fastUnescapeHtml(value); + } + + /* + * replacement for JSONObject.optBoolean() - optBoolean() only checks for "true" and "false", + * but our API sometimes uses "0" to denote false + */ + public static boolean getBool(JSONObject json, String name) { + String value = getString(json, name); + if (TextUtils.isEmpty(value)) + return false; + if (value.equals("0")) + return false; + if (value.equalsIgnoreCase("false")) + return false; + return true; + } + + /* + * returns the JSONObject child of the passed parent that matches the passed query + * this is basically an "optJSONObject" that supports nested queries, for example: + * + * getJSONChild("meta/data/site") + * + * would find this: + * + * "meta": { + * "data": { + * "site": { + * "ID": 3584907, + * "name": "WordPress.com News", + * } + * } + * } + */ + public static JSONObject getJSONChild(final JSONObject jsonParent, final String query) { + if (jsonParent == null || TextUtils.isEmpty(query)) + return null; + String[] names = query.split("/"); + JSONObject jsonChild = null; + for (int i = 0; i < names.length; i++) { + if (jsonChild == null) { + jsonChild = jsonParent.optJSONObject(names[i]); + } else { + jsonChild = jsonChild.optJSONObject(names[i]); + } + if (jsonChild == null) + return null; + } + return jsonChild; + } +} \ No newline at end of file diff --git a/WordPressUtils/src/main/java/org/wordpress/android/util/ListScrollPositionManager.java b/WordPressUtils/src/main/java/org/wordpress/android/util/ListScrollPositionManager.java new file mode 100644 index 000000000000..d60e9da6c6b2 --- /dev/null +++ b/WordPressUtils/src/main/java/org/wordpress/android/util/ListScrollPositionManager.java @@ -0,0 +1,36 @@ +package org.wordpress.android.util; + +import android.view.View; +import android.widget.ListView; + +public class ListScrollPositionManager { + private int mSelectedPosition; + private int mListViewScrollStateIndex; + private int mListViewScrollStateOffset; + private ListView mListView; + private boolean mSetSelection; + + public ListScrollPositionManager(ListView listView, boolean setSelection) { + mListView = listView; + mSetSelection = setSelection; + } + + public void saveScrollOffset() { + mListViewScrollStateIndex = mListView.getFirstVisiblePosition(); + View view = mListView.getChildAt(0); + mListViewScrollStateOffset = 0; + if (view != null) { + mListViewScrollStateOffset = view.getTop(); + } + if (mSetSelection) { + mSelectedPosition = mListView.getCheckedItemPosition(); + } + } + + public void restoreScrollOffset() { + mListView.setSelectionFromTop(mListViewScrollStateIndex, mListViewScrollStateOffset); + if (mSetSelection) { + mListView.setItemChecked(mSelectedPosition, true); + } + } +} diff --git a/WordPressUtils/src/main/java/org/wordpress/android/util/LocationHelper.java b/WordPressUtils/src/main/java/org/wordpress/android/util/LocationHelper.java new file mode 100644 index 000000000000..12439fd28c87 --- /dev/null +++ b/WordPressUtils/src/main/java/org/wordpress/android/util/LocationHelper.java @@ -0,0 +1,132 @@ +//This Handy-Dandy class acquired and tweaked from http://stackoverflow.com/a/3145655/309558 +package org.wordpress.android.util; + +import java.util.Timer; +import java.util.TimerTask; + +import android.content.Context; +import android.location.Location; +import android.location.LocationListener; +import android.location.LocationManager; +import android.os.Bundle; + +public class LocationHelper { + Timer timer1; + LocationManager lm; + LocationResult locationResult; + boolean gps_enabled = false; + boolean network_enabled = false; + + public boolean getLocation(Context context, LocationResult result) { + locationResult = result; + if (lm == null) + lm = (LocationManager) context + .getSystemService(Context.LOCATION_SERVICE); + + // exceptions will be thrown if provider is not permitted. + try { + gps_enabled = lm.isProviderEnabled(LocationManager.GPS_PROVIDER); + } catch (Exception ex) { + } + try { + network_enabled = lm.isProviderEnabled(LocationManager.NETWORK_PROVIDER); + } catch (Exception ex) { + } + + // don't start listeners if no provider is enabled + if (!gps_enabled && !network_enabled) + return false; + + if (gps_enabled) + lm.requestLocationUpdates(LocationManager.GPS_PROVIDER, 0, 0, locationListenerGps); + + if (network_enabled) + lm.requestLocationUpdates(LocationManager.NETWORK_PROVIDER, 0, 0, locationListenerNetwork); + + timer1 = new Timer(); + timer1.schedule(new GetLastLocation(), 30000); + return true; + } + + LocationListener locationListenerGps = new LocationListener() { + public void onLocationChanged(Location location) { + timer1.cancel(); + locationResult.gotLocation(location); + lm.removeUpdates(this); + lm.removeUpdates(locationListenerNetwork); + } + + public void onProviderDisabled(String provider) { + } + + public void onProviderEnabled(String provider) { + } + + public void onStatusChanged(String provider, int status, Bundle extras) { + } + }; + + LocationListener locationListenerNetwork = new LocationListener() { + public void onLocationChanged(Location location) { + timer1.cancel(); + locationResult.gotLocation(location); + lm.removeUpdates(this); + lm.removeUpdates(locationListenerGps); + } + + public void onProviderDisabled(String provider) { + } + + public void onProviderEnabled(String provider) { + } + + public void onStatusChanged(String provider, int status, Bundle extras) { + } + }; + + class GetLastLocation extends TimerTask { + @Override + public void run() { + lm.removeUpdates(locationListenerGps); + lm.removeUpdates(locationListenerNetwork); + + Location net_loc = null, gps_loc = null; + if (gps_enabled) + gps_loc = lm.getLastKnownLocation(LocationManager.GPS_PROVIDER); + if (network_enabled) + net_loc = lm + .getLastKnownLocation(LocationManager.NETWORK_PROVIDER); + + // if there are both values use the latest one + if (gps_loc != null && net_loc != null) { + if (gps_loc.getTime() > net_loc.getTime()) + locationResult.gotLocation(gps_loc); + else + locationResult.gotLocation(net_loc); + return; + } + + if (gps_loc != null) { + locationResult.gotLocation(gps_loc); + return; + } + if (net_loc != null) { + locationResult.gotLocation(net_loc); + return; + } + locationResult.gotLocation(null); + } + } + + public static abstract class LocationResult { + public abstract void gotLocation(Location location); + } + + public void cancelTimer() { + if (timer1 != null) { + timer1.cancel(); + lm.removeUpdates(locationListenerGps); + lm.removeUpdates(locationListenerNetwork); + } + } +} diff --git a/WordPressUtils/src/main/java/org/wordpress/android/util/MapUtils.java b/WordPressUtils/src/main/java/org/wordpress/android/util/MapUtils.java new file mode 100644 index 000000000000..981e537d257a --- /dev/null +++ b/WordPressUtils/src/main/java/org/wordpress/android/util/MapUtils.java @@ -0,0 +1,79 @@ +package org.wordpress.android.util; + +import java.util.Date; +import java.util.Map; + +/** + * wrappers for extracting values from a Map object + */ +public class MapUtils { + /* + * returns a String value for the passed key in the passed map + * always returns "" instead of null + */ + public static String getMapStr(final Map map, final String key) { + if (map == null || key == null || !map.containsKey(key) || map.get(key) == null) { + return ""; + } + return map.get(key).toString(); + } + + /* + * returns an int value for the passed key in the passed map + * defaultValue is returned if key doesn't exist or isn't a number + */ + public static int getMapInt(final Map map, final String key) { + return getMapInt(map, key, 0); + } + public static int getMapInt(final Map map, final String key, int defaultValue) { + try { + return Integer.parseInt(getMapStr(map, key)); + } catch (NumberFormatException e) { + return defaultValue; + } + } + + /* + * long version of above + */ + public static long getMapLong(final Map map, final String key) { + return getMapLong(map, key, 0); + } + public static long getMapLong(final Map map, final String key, long defaultValue) { + try { + return Long.parseLong(getMapStr(map, key)); + } catch (NumberFormatException e) { + return defaultValue; + } + } + + /* + * returns a date object from the passed key in the passed map + * returns null if key doesn't exist or isn't a date + */ + public static Date getMapDate(final Map map, final String key) { + if (map==null || key==null || !map.containsKey(key)) + return null; + try { + return (Date) map.get(key); + } catch (ClassCastException e) { + return null; + } + } + + /* + * returns a boolean value from the passed key in the passed map + * returns true unless key doesn't exist, or the value is "0" or "false" + */ + public static boolean getMapBool(final Map map, final String key) { + String value = getMapStr(map, key); + if (value.isEmpty()) + return false; + if (value.startsWith("0")) // handles "0" and "0.0" + return false; + if (value.equalsIgnoreCase("false")) + return false; + // all other values are assume to be true + return true; + } +} diff --git a/WordPressUtils/src/main/java/org/wordpress/android/util/PhotonUtils.java b/WordPressUtils/src/main/java/org/wordpress/android/util/PhotonUtils.java new file mode 100644 index 000000000000..497d756ee377 --- /dev/null +++ b/WordPressUtils/src/main/java/org/wordpress/android/util/PhotonUtils.java @@ -0,0 +1,96 @@ +package org.wordpress.android.util; + +import android.text.TextUtils; + +/** + * routines related to the Photon API + * http://developer.wordpress.com/docs/photon/ + */ +public class PhotonUtils { + private PhotonUtils() { + throw new AssertionError(); + } + + /* + * gravatars often contain the ?s= parameter which determines their size - detect this and + * replace it with a new ?s= parameter which requests the avatar at the exact size needed + */ + public static String fixAvatar(final String imageUrl, int avatarSz) { + if (TextUtils.isEmpty(imageUrl)) + return ""; + + // if this isn't a gravatar image, return as resized photon image url + if (!imageUrl.contains("gravatar.com")) + return getPhotonImageUrl(imageUrl, avatarSz, avatarSz); + + // remove all other params, then add query string for size and "mystery man" default + return UrlUtils.removeQuery(imageUrl) + String.format("?s=%d&d=mm", avatarSz); + } + + /* + * returns true if the passed url is an obvious "mshots" url + */ + public static boolean isMshotsUrl(final String imageUrl) { + return (imageUrl != null && imageUrl.contains("/mshots/")); + } + + /* + * returns a photon url for the passed image with the resize query set to the passed dimensions + */ + public static String getPhotonImageUrl(String imageUrl, int width, int height) { + if (TextUtils.isEmpty(imageUrl)) { + return ""; + } + + // make sure it's valid + int schemePos = imageUrl.indexOf("://"); + if (schemePos == -1) { + return imageUrl; + } + + // remove existing query string since it may contain params that conflict with the passed ones + imageUrl = UrlUtils.removeQuery(imageUrl); + + // don't use with GIFs - photon breaks animated GIFs, and sometimes returns a GIF that + // can't be read by BitmapFactory.decodeByteArray (used by Volley in ImageRequest.java + // to decode the downloaded image) + // ex: http://i0.wp.com/lusianne.files.wordpress.com/2013/08/193.gif?resize=768,320 + if (imageUrl.endsWith(".gif")) { + return imageUrl; + } + + // if this is an "mshots" url, skip photon and return it with a query that sets the width/height + // (these are screenshots of the blog that often appear in freshly pressed posts) + // see http://wp.tutsplus.com/tutorials/how-to-generate-website-screenshots-for-your-wordpress-site/ + // ex: http://s.wordpress.com/mshots/v1/http%3A%2F%2Fnickbradbury.com?w=600 + if (isMshotsUrl(imageUrl)) { + return imageUrl + String.format("?w=%d&h=%d", width, height); + } + + // if both width & height are passed use the "resize" param, use only "w" or "h" if just + // one of them is set, otherwise no query string + final String query; + if (width > 0 && height > 0) { + query = String.format("?resize=%d,%d", width, height); + } else if (width > 0) { + query = String.format("?w=%d", width); + } else if (height > 0) { + query = String.format("?h=%d", height); + } else { + query = ""; + } + + // return passed url+query if it's already a photon url + if (imageUrl.contains(".wp.com")) { + if (imageUrl.contains("i0.wp.com") || imageUrl.contains("i1.wp.com") || imageUrl.contains("i2.wp.com")) + return imageUrl + query; + } + + // must use https for https image urls + if (UrlUtils.isHttps(imageUrl)) { + return "https://i0.wp.com/" + imageUrl.substring(schemePos+3, imageUrl.length()) + query; + } else { + return "http://i0.wp.com/" + imageUrl.substring(schemePos+3, imageUrl.length()) + query; + } + } +} diff --git a/WordPressUtils/src/main/java/org/wordpress/android/util/ProfilingUtils.java b/WordPressUtils/src/main/java/org/wordpress/android/util/ProfilingUtils.java new file mode 100644 index 000000000000..251db2a3b7fb --- /dev/null +++ b/WordPressUtils/src/main/java/org/wordpress/android/util/ProfilingUtils.java @@ -0,0 +1,91 @@ +package org.wordpress.android.util; + +import android.content.Context; +import android.content.pm.PackageInfo; +import android.content.pm.PackageManager; +import android.os.SystemClock; + +import org.wordpress.android.util.AppLog.T; + +import java.util.ArrayList; + +/** + * forked from android.util.TimingLogger to use AppLog instead of Log + new static interface. + */ +public class ProfilingUtils { + private static ProfilingUtils sInstance; + + private String mLabel; + private ArrayList mSplits; + private ArrayList mSplitLabels; + + public static void start(String label) { + getInstance().reset(label); + } + + public static void split(String splitLabel) { + getInstance().addSplit(splitLabel); + } + + public static void dump() { + getInstance().dumpToLog(); + } + + private static ProfilingUtils getInstance() { + if (sInstance == null) { + sInstance = new ProfilingUtils(); + } + return sInstance; + } + + public ProfilingUtils() { + reset("init"); + } + + public void reset(String label) { + mLabel = label; + reset(); + } + + public void reset() { + if (mSplits == null) { + mSplits = new ArrayList(); + mSplitLabels = new ArrayList(); + } else { + mSplits.clear(); + mSplitLabels.clear(); + } + addSplit(null); + } + + public void addSplit(String splitLabel) { + long now = SystemClock.elapsedRealtime(); + mSplits.add(now); + mSplitLabels.add(splitLabel); + } + + public void dumpToLog() { + AppLog.d(T.PROFILING, mLabel + ": begin"); + final long first = mSplits.get(0); + long now = first; + for (int i = 1; i < mSplits.size(); i++) { + now = mSplits.get(i); + final String splitLabel = mSplitLabels.get(i); + final long prev = mSplits.get(i - 1); + AppLog.d(T.PROFILING, mLabel + ": " + (now - prev) + " ms, " + splitLabel); + } + AppLog.d(T.PROFILING, mLabel + ": end, " + (now - first) + " ms"); + } + + // Returns app version name String + public static String getVersionName(Context context) { + PackageManager pm = context.getPackageManager(); + try { + PackageInfo pi = pm.getPackageInfo(context.getPackageName(), 0); + return pi.versionName == null ? "" : pi.versionName; + } catch (PackageManager.NameNotFoundException e) { + return ""; + } + } +} + diff --git a/WordPressUtils/src/main/java/org/wordpress/android/util/README.md b/WordPressUtils/src/main/java/org/wordpress/android/util/README.md new file mode 100644 index 000000000000..62a759585e63 --- /dev/null +++ b/WordPressUtils/src/main/java/org/wordpress/android/util/README.md @@ -0,0 +1 @@ +# org.wordpress.android.util \ No newline at end of file diff --git a/WordPressUtils/src/main/java/org/wordpress/android/util/SqlUtils.java b/WordPressUtils/src/main/java/org/wordpress/android/util/SqlUtils.java new file mode 100644 index 000000000000..8d1b4b4379c9 --- /dev/null +++ b/WordPressUtils/src/main/java/org/wordpress/android/util/SqlUtils.java @@ -0,0 +1,121 @@ +package org.wordpress.android.util; + +import android.database.Cursor; +import android.database.DatabaseUtils; +import android.database.sqlite.SQLiteDatabase; +import android.database.sqlite.SQLiteDoneException; +import android.database.sqlite.SQLiteException; +import android.database.sqlite.SQLiteStatement; + +import java.util.ArrayList; +import java.util.List; + +public class SqlUtils { + private SqlUtils() { + throw new AssertionError(); + } + + /* + * SQLite doesn't have a boolean datatype, so booleans are stored as 0=false, 1=true + */ + public static long boolToSql(boolean value) { + return (value ? 1 : 0); + } + public static boolean sqlToBool(int value) { + return (value != 0); + } + + public static void closeStatement(SQLiteStatement stmt) { + if (stmt != null) { + stmt.close(); + } + } + + public static void closeCursor(Cursor c) { + if (c != null && !c.isClosed()) { + c.close(); + } + } + + /* + * wrapper for DatabaseUtils.longForQuery() which returns 0 if query returns no rows + */ + public static long longForQuery(SQLiteDatabase db, String query, String[] selectionArgs) { + try { + return DatabaseUtils.longForQuery(db, query, selectionArgs); + } catch (SQLiteDoneException e) { + return 0; + } + } + + public static int intForQuery(SQLiteDatabase db, String query, String[] selectionArgs) { + long value = longForQuery(db, query, selectionArgs); + return (int)value; + } + + public static boolean boolForQuery(SQLiteDatabase db, String query, String[] selectionArgs) { + long value = longForQuery(db, query, selectionArgs); + return sqlToBool((int) value); + } + + /* + * wrapper for DatabaseUtils.stringForQuery(), returns "" if query returns no rows + */ + public static String stringForQuery(SQLiteDatabase db, String query, String[] selectionArgs) { + try { + return DatabaseUtils.stringForQuery(db, query, selectionArgs); + } catch (SQLiteDoneException e) { + return ""; + } + } + + /* + * returns the number of rows in the passed table + */ + public static long getRowCount(SQLiteDatabase db, String tableName) { + return DatabaseUtils.queryNumEntries(db, tableName); + } + + /* + * removes all rows from the passed table + */ + public static void deleteAllRowsInTable(SQLiteDatabase db, String tableName) { + db.delete(tableName, null, null); + } + + /* + * drop all tables from the passed SQLiteDatabase - make sure to pass a + * writable database + */ + public static boolean dropAllTables(SQLiteDatabase db) throws SQLiteException { + if (db == null) { + return false; + } + + if (db.isReadOnly()) { + throw new SQLiteException("can't drop tables from a read-only database"); + } + + List tableNames = new ArrayList(); + Cursor cursor = db.rawQuery("SELECT name FROM sqlite_master WHERE type='table'", null); + if (cursor.moveToFirst()) { + do { + String tableName = cursor.getString(0); + if (!tableName.equals("android_metadata") && !tableName.equals("sqlite_sequence")) { + tableNames.add(tableName); + } + } while (cursor.moveToNext()); + } + + db.beginTransaction(); + try { + for (String tableName: tableNames) { + db.execSQL("DROP TABLE IF EXISTS " + tableName); + } + db.setTransactionSuccessful(); + return true; + } finally { + db.endTransaction(); + } + } +} diff --git a/WordPressUtils/src/main/java/org/wordpress/android/util/StringUtils.java b/WordPressUtils/src/main/java/org/wordpress/android/util/StringUtils.java new file mode 100644 index 000000000000..eca31ffd169d --- /dev/null +++ b/WordPressUtils/src/main/java/org/wordpress/android/util/StringUtils.java @@ -0,0 +1,278 @@ +package org.wordpress.android.util; + +import android.text.Html; +import android.text.TextUtils; + +import org.wordpress.android.util.AppLog.T; + +import java.math.BigInteger; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +public class StringUtils { + public static String[] mergeStringArrays(String array1[], String array2[]) { + if (array1 == null || array1.length == 0) { + return array2; + } + if (array2 == null || array2.length == 0) { + return array1; + } + List array1List = Arrays.asList(array1); + List array2List = Arrays.asList(array2); + List result = new ArrayList(array1List); + List tmp = new ArrayList(array1List); + tmp.retainAll(array2List); + result.addAll(array2List); + return ((String[]) result.toArray(new String[result.size()])); + } + + public static String convertHTMLTagsForUpload(String source) { + // bold + source = source.replace("", ""); + source = source.replace("", ""); + + // italics + source = source.replace("", ""); + source = source.replace("", ""); + + return source; + } + + public static String convertHTMLTagsForDisplay(String source) { + // bold + source = source.replace("", ""); + source = source.replace("", ""); + + // italics + source = source.replace("", ""); + source = source.replace("", ""); + + return source; + } + + public static String addPTags(String source) { + String[] asploded = source.split("\n\n"); + + if (asploded.length > 0) { + StringBuilder wrappedHTML = new StringBuilder(); + for (int i = 0; i < asploded.length; i++) { + String trimmed = asploded[i].trim(); + if (trimmed.length() > 0) { + trimmed = trimmed.replace("
        ", "
        ").replace("
        ", "
        ").replace("
        \n", "
        ") + .replace("\n", "
        "); + wrappedHTML.append("

        "); + wrappedHTML.append(trimmed); + wrappedHTML.append("

        "); + } + } + return wrappedHTML.toString(); + } else { + return source; + } + } + + public static BigInteger getMd5IntHash(String input) { + try { + MessageDigest md = MessageDigest.getInstance("MD5"); + byte[] messageDigest = md.digest(input.getBytes()); + BigInteger number = new BigInteger(1, messageDigest); + return number; + } catch (NoSuchAlgorithmException e) { + AppLog.e(T.UTILS, e); + return null; + } + } + + public static String getMd5Hash(String input) { + BigInteger number = getMd5IntHash(input); + String md5 = number.toString(16); + while (md5.length() < 32) { + md5 = "0" + md5; + } + return md5; + } + + public static String unescapeHTML(String html) { + if (html != null) { + return Html.fromHtml(html).toString(); + } else { + return ""; + } + } + + /* + * nbradbury - adapted from Html.escapeHtml(), which was added in API Level 16 + * TODO: not thoroughly tested yet, so marked as private - not sure I like the way + * this replaces two spaces with " " + */ + private static String escapeHtml(final String text) { + if (text == null) { + return ""; + } + + StringBuilder out = new StringBuilder(); + int length = text.length(); + + for (int i = 0; i < length; i++) { + char c = text.charAt(i); + + if (c == '<') { + out.append("<"); + } else if (c == '>') { + out.append(">"); + } else if (c == '&') { + out.append("&"); + } else if (c > 0x7E || c < ' ') { + out.append("&#").append((int) c).append(";"); + } else if (c == ' ') { + while (i + 1 < length && text.charAt(i + 1) == ' ') { + out.append(" "); + i++; + } + + out.append(' '); + } else { + out.append(c); + } + } + + return out.toString(); + } + + /* + * returns empty string if passed string is null, otherwise returns passed string + */ + public static String notNullStr(String s) { + if (s == null) { + return ""; + } + return s; + } + + /** + * returns true if two strings are equal or two strings are null + */ + public static boolean equals(String s1, String s2) { + if (s1 == null) { + return s2 == null; + } + return s1.equals(s2); + } + + /* + * capitalizes the first letter in the passed string - based on Apache commons/lang3/StringUtils + * http://svn.apache.org/viewvc/commons/proper/lang/trunk/src/main/java/org/apache/commons/lang3/StringUtils.java?revision=1497829&view=markup + */ + public static String capitalize(final String str) { + int strLen; + if (str == null || (strLen = str.length()) == 0) { + return str; + } + + char firstChar = str.charAt(0); + if (Character.isTitleCase(firstChar)) { + return str; + } + + return new StringBuilder(strLen).append(Character.toTitleCase(firstChar)).append(str.substring(1)).toString(); + } + + /* + * Wrap an image URL in a photon URL + * Check out http://developer.wordpress.com/docs/photon/ + */ + public static String getPhotonUrl(String imageUrl, int size) { + imageUrl = imageUrl.replace("http://", "").replace("https://", ""); + return "http://i0.wp.com/" + imageUrl + "?w=" + size; + } + + public static String getHost(String url) { + if (TextUtils.isEmpty(url)) { + return ""; + } + + int doubleslash = url.indexOf("//"); + if (doubleslash == -1) { + doubleslash = 0; + } else { + doubleslash += 2; + } + + int end = url.indexOf('/', doubleslash); + end = (end >= 0) ? end : url.length(); + + return url.substring(doubleslash, end); + } + + public static String replaceUnicodeSurrogateBlocksWithHTMLEntities(final String inputString) { + final int length = inputString.length(); + StringBuilder out = new StringBuilder(); // Used to hold the output. + for (int offset = 0; offset < length; ) { + final int codepoint = inputString.codePointAt(offset); + final char current = inputString.charAt(offset); + if (Character.isHighSurrogate(current) || Character.isLowSurrogate(current)) { + if (Emoticons.wpSmiliesCodePointToText.get(codepoint) != null) { + out.append(Emoticons.wpSmiliesCodePointToText.get(codepoint)); + } else { + final String htmlEscapedChar = "&#x" + Integer.toHexString(codepoint) + ";"; + out.append(htmlEscapedChar); + } + } else { + out.append(current); + } + offset += Character.charCount(codepoint); + } + return out.toString(); + } + + /** + * This method ensures that the output String has only + * valid XML unicode characters as specified by the + * XML 1.0 standard. For reference, please see + * the + * standard. This method will return an empty + * String if the input is null or empty. + * + * @param in The String whose non-valid characters we want to remove. + * @return The in String, stripped of non-valid characters. + */ + public static final String stripNonValidXMLCharacters(String in) { + StringBuilder out = new StringBuilder(); // Used to hold the output. + char current; // Used to reference the current character. + + if (in == null || ("".equals(in))) { + return ""; // vacancy test. + } + for (int i = 0; i < in.length(); i++) { + current = in.charAt(i); // NOTE: No IndexOutOfBoundsException caught here; it should not happen. + if ((current == 0x9) || + (current == 0xA) || + (current == 0xD) || + ((current >= 0x20) && (current <= 0xD7FF)) || + ((current >= 0xE000) && (current <= 0xFFFD)) || + ((current >= 0x10000) && (current <= 0x10FFFF))) { + out.append(current); + } + } + return out.toString(); + } + + /* + * simple wrapper for Integer.valueOf(string) so caller doesn't need to catch NumberFormatException + */ + public static int stringToInt(String s) { + return stringToInt(s, 0); + } + public static int stringToInt(String s, int defaultValue) { + if (s == null) + return defaultValue; + try { + return Integer.valueOf(s); + } catch (NumberFormatException e) { + return defaultValue; + } + } +} \ No newline at end of file diff --git a/WordPressUtils/src/main/java/org/wordpress/android/util/SystemServiceFactory.java b/WordPressUtils/src/main/java/org/wordpress/android/util/SystemServiceFactory.java new file mode 100644 index 000000000000..4ba0c96ed589 --- /dev/null +++ b/WordPressUtils/src/main/java/org/wordpress/android/util/SystemServiceFactory.java @@ -0,0 +1,17 @@ +package org.wordpress.android.util; + +import android.content.Context; + +import org.wordpress.android.util.AppLog.T; + +public class SystemServiceFactory { + public static SystemServiceFactoryAbstract sFactory; + + public static Object get(Context context, String name) { + if (sFactory == null) { + sFactory = new SystemServiceFactoryDefault(); + } + AppLog.v(T.UTILS, "instantiate SystemService using sFactory: " + sFactory.getClass()); + return sFactory.get(context, name); + } +} diff --git a/WordPressUtils/src/main/java/org/wordpress/android/util/SystemServiceFactoryAbstract.java b/WordPressUtils/src/main/java/org/wordpress/android/util/SystemServiceFactoryAbstract.java new file mode 100644 index 000000000000..a9d522db4c1c --- /dev/null +++ b/WordPressUtils/src/main/java/org/wordpress/android/util/SystemServiceFactoryAbstract.java @@ -0,0 +1,7 @@ +package org.wordpress.android.util; + +import android.content.Context; + +public interface SystemServiceFactoryAbstract { + public Object get(Context context, String name); +} diff --git a/WordPressUtils/src/main/java/org/wordpress/android/util/SystemServiceFactoryDefault.java b/WordPressUtils/src/main/java/org/wordpress/android/util/SystemServiceFactoryDefault.java new file mode 100644 index 000000000000..eb488dde9bf4 --- /dev/null +++ b/WordPressUtils/src/main/java/org/wordpress/android/util/SystemServiceFactoryDefault.java @@ -0,0 +1,9 @@ +package org.wordpress.android.util; + +import android.content.Context; + +public class SystemServiceFactoryDefault implements SystemServiceFactoryAbstract { + public Object get(Context context, String name) { + return context.getSystemService(name); + } +} diff --git a/WordPressUtils/src/main/java/org/wordpress/android/util/ToastUtils.java b/WordPressUtils/src/main/java/org/wordpress/android/util/ToastUtils.java new file mode 100644 index 000000000000..9b99c6ea53e1 --- /dev/null +++ b/WordPressUtils/src/main/java/org/wordpress/android/util/ToastUtils.java @@ -0,0 +1,37 @@ +package org.wordpress.android.util; + +import android.content.Context; +import android.view.Gravity; +import android.widget.Toast; + +/** + * Provides a simplified way to show toast messages without having to create the toast, set the + * desired gravity, etc. + */ +public class ToastUtils { + public enum Duration {SHORT, LONG} + + private ToastUtils() { + throw new AssertionError(); + } + + public static Toast showToast(Context context, int stringResId) { + return showToast(context, stringResId, Duration.SHORT); + } + + public static Toast showToast(Context context, int stringResId, Duration duration) { + return showToast(context, context.getString(stringResId), duration); + } + + public static Toast showToast(Context context, String text) { + return showToast(context, text, Duration.SHORT); + } + + public static Toast showToast(Context context, String text, Duration duration) { + Toast toast = Toast.makeText(context, text, + (duration == Duration.SHORT ? Toast.LENGTH_SHORT : Toast.LENGTH_LONG)); + toast.setGravity(Gravity.CENTER, 0, 0); + toast.show(); + return toast; + } +} diff --git a/WordPressUtils/src/main/java/org/wordpress/android/util/UrlUtils.java b/WordPressUtils/src/main/java/org/wordpress/android/util/UrlUtils.java new file mode 100644 index 000000000000..4438b8950158 --- /dev/null +++ b/WordPressUtils/src/main/java/org/wordpress/android/util/UrlUtils.java @@ -0,0 +1,165 @@ +package org.wordpress.android.util; + +import android.net.Uri; +import android.webkit.MimeTypeMap; +import android.webkit.URLUtil; + +import java.io.UnsupportedEncodingException; +import java.net.IDN; +import java.net.URI; +import java.net.URLDecoder; +import java.net.URLEncoder; +import java.nio.charset.Charset; + +public class UrlUtils { + public static String urlEncode(final String text) { + try { + return URLEncoder.encode(text, "UTF-8"); + } catch (UnsupportedEncodingException e) { + return text; + } + } + + public static String urlDecode(final String text) { + try { + return URLDecoder.decode(text, "UTF-8"); + } catch (UnsupportedEncodingException e) { + return text; + } + } + + public static String getDomainFromUrl(final String urlString) { + if (urlString == null) { + return ""; + } + Uri uri = Uri.parse(urlString); + return uri.getHost(); + } + + /** + * Convert IDN names to punycode if necessary + */ + public static String convertUrlToPunycodeIfNeeded(String url) { + if (!Charset.forName("US-ASCII").newEncoder().canEncode(url)) { + if (url.toLowerCase().startsWith("http://")) { + url = "http://" + IDN.toASCII(url.substring(7)); + } else if (url.toLowerCase().startsWith("https://")) { + url = "https://" + IDN.toASCII(url.substring(8)); + } else { + url = IDN.toASCII(url); + } + } + return url; + } + + public static String addUrlSchemeIfNeeded(String url, boolean isHTTPS) { + if (url == null) { + return null; + } + + if (!URLUtil.isValidUrl(url)) { + if (!(url.toLowerCase().startsWith("http://")) && !(url.toLowerCase().startsWith("https://"))) { + url = (isHTTPS ? "https" : "http") + "://" + url; + } + } + + return url; + } + + /** + * normalizes a URL, primarily for comparison purposes, for example so that + * normalizeUrl("http://google.com/") = normalizeUrl("http://google.com") + */ + public static String normalizeUrl(final String urlString) { + if (urlString == null) { + return null; + } + + // this routine is called from some performance-critical code and creating a URI from a string + // is slow, so skip it when possible - if we know it's not a relative path (and 99.9% of the + // time it won't be for our purposes) then we can normalize it without java.net.URI.normalize() + if (urlString.startsWith("http") && !urlString.contains("build/intermediates/exploded-aar/org.wordpress/graphview/3.1.1")) { + // return without a trailing slash + if (urlString.endsWith("/")) { + return urlString.substring(0, urlString.length() - 1); + } + return urlString; + } + + // url is relative, so fall back to using slower java.net.URI normalization + try { + URI uri = URI.create(urlString); + return uri.normalize().toString(); + } catch (IllegalArgumentException e) { + return urlString; + } + } + + /** + * returns the passed url without the query parameters + */ + public static String removeQuery(final String urlString) { + if (urlString == null) { + return null; + } + int pos = urlString.indexOf("?"); + if (pos == -1) { + return urlString; + } + return urlString.substring(0, pos); + } + + /** + * returns true if passed url is https: + */ + public static boolean isHttps(final String urlString) { + return (urlString != null && urlString.startsWith("https:")); + } + + /** + * returns https: version of passed http: url + */ + public static String makeHttps(final String urlString) { + if (urlString == null || !urlString.startsWith("http:")) { + return urlString; + } + return "https:" + urlString.substring(5, urlString.length()); + } + + /** + * see http://stackoverflow.com/a/8591230/1673548 + */ + public static String getUrlMimeType(final String urlString) { + if (urlString == null) { + return null; + } + + String extension = MimeTypeMap.getFileExtensionFromUrl(urlString); + if (extension == null) { + return null; + } + + MimeTypeMap mime = MimeTypeMap.getSingleton(); + String mimeType = mime.getMimeTypeFromExtension(extension); + if (mimeType == null) { + return null; + } + + return mimeType; + } + + /** + * returns false if the url is not valid or if the url host is null, else true + */ + public static boolean isValidUrlAndHostNotNull(String url) { + try { + URI uri = URI.create(url); + if (uri.getHost() == null) { + return false; + } + } catch (IllegalArgumentException e) { + return false; + } + return true; + } +} diff --git a/WordPressUtils/src/main/java/org/wordpress/android/util/UserEmail.java b/WordPressUtils/src/main/java/org/wordpress/android/util/UserEmail.java new file mode 100644 index 000000000000..dae02b4f01b3 --- /dev/null +++ b/WordPressUtils/src/main/java/org/wordpress/android/util/UserEmail.java @@ -0,0 +1,35 @@ +package org.wordpress.android.util; + +import android.accounts.Account; +import android.accounts.AccountManager; +import android.content.Context; +import android.util.Patterns; + +import java.util.regex.Pattern; + +public class UserEmail { + /** + * Get primary account and return its name if it matches the email address pattern. + * + * @return primary account email address if it can be found or empty string else. + */ + public static String getPrimaryEmail(Context context) { + try { + AccountManager accountManager = AccountManager.get(context); + if (accountManager == null) + return ""; + Account[] accounts = accountManager.getAccounts(); + Pattern emailPattern = Patterns.EMAIL_ADDRESS; + for (Account account : accounts) { + // make sure account.name is an email address before adding to the list + if (emailPattern.matcher(account.name).matches()) { + return account.name; + } + } + return ""; + } catch (SecurityException e) { + // exception will occur if app doesn't have GET_ACCOUNTS permission + return ""; + } + } +} diff --git a/WordPressUtils/src/main/java/org/wordpress/android/util/Version.java b/WordPressUtils/src/main/java/org/wordpress/android/util/Version.java new file mode 100644 index 000000000000..6e695db454da --- /dev/null +++ b/WordPressUtils/src/main/java/org/wordpress/android/util/Version.java @@ -0,0 +1,47 @@ +package org.wordpress.android.util; + +//See: http://stackoverflow.com/a/11024200 +public class Version implements Comparable { + private String version; + + public final String get() { + return this.version; + } + + public Version(String version) { + if(version == null) + throw new IllegalArgumentException("Version can not be null"); + if(!version.matches("[0-9]+(\\.[0-9]+)*")) + throw new IllegalArgumentException("Invalid version format"); + this.version = version; + } + + @Override public int compareTo(Version that) { + if(that == null) + return 1; + String[] thisParts = this.get().split("\\."); + String[] thatParts = that.get().split("\\."); + int length = Math.max(thisParts.length, thatParts.length); + for(int i = 0; i < length; i++) { + int thisPart = i < thisParts.length ? + Integer.parseInt(thisParts[i]) : 0; + int thatPart = i < thatParts.length ? + Integer.parseInt(thatParts[i]) : 0; + if(thisPart < thatPart) + return -1; + if(thisPart > thatPart) + return 1; + } + return 0; + } + + @Override public boolean equals(Object that) { + if(this == that) + return true; + if(that == null) + return false; + if(this.getClass() != that.getClass()) + return false; + return this.compareTo((Version) that) == 0; + } +} \ No newline at end of file diff --git a/WordPressUtils/src/main/java/org/wordpress/android/util/WPHtmlTagHandler.java b/WordPressUtils/src/main/java/org/wordpress/android/util/WPHtmlTagHandler.java new file mode 100644 index 000000000000..fa96a998a23c --- /dev/null +++ b/WordPressUtils/src/main/java/org/wordpress/android/util/WPHtmlTagHandler.java @@ -0,0 +1,59 @@ +package org.wordpress.android.util; + +import android.text.Editable; +import android.text.Html; +import android.text.style.BulletSpan; +import android.text.style.LeadingMarginSpan; + +import org.xml.sax.XMLReader; + +import java.util.Vector; + +/** + * Handle tags that the Html class doesn't understand + * Tweaked from source at http://stackoverflow.com/questions/4044509/android-how-to-use-the-html-taghandler + */ +public class WPHtmlTagHandler implements Html.TagHandler { + private int mListItemCount = 0; + private Vector mListParents = new Vector(); + + @Override + public void handleTag(final boolean opening, final String tag, Editable output, + final XMLReader xmlReader) { + if (tag.equals("ul") || tag.equals("ol") || tag.equals("dd")) { + if (opening) { + mListParents.add(tag); + } else { + mListParents.remove(tag); + } + mListItemCount = 0; + } else if (tag.equals("li") && !opening) { + handleListTag(output); + } + } + + private void handleListTag(Editable output) { + if (mListParents.lastElement().equals("ul")) { + output.append("\n"); + String[] split = output.toString().split("\n"); + int start = 0; + if (split.length != 1) { + int lastIndex = split.length - 1; + start = output.length() - split[lastIndex].length() - 1; + } + output.setSpan(new BulletSpan(15 * mListParents.size()), start, output.length(), 0); + } else if (mListParents.lastElement().equals("ol")) { + mListItemCount++; + output.append("\n"); + String[] split = output.toString().split("\n"); + int start = 0; + if (split.length != 1) { + int lastIndex = split.length - 1; + start = output.length() - split[lastIndex].length() - 1; + } + output.insert(start, mListItemCount + ". "); + output.setSpan(new LeadingMarginSpan.Standard(15 * mListParents.size()), start, + output.length(), 0); + } + } +} diff --git a/WordPressUtils/src/main/java/org/wordpress/android/util/WPImageGetter.java b/WordPressUtils/src/main/java/org/wordpress/android/util/WPImageGetter.java new file mode 100644 index 000000000000..60b0d605b4d4 --- /dev/null +++ b/WordPressUtils/src/main/java/org/wordpress/android/util/WPImageGetter.java @@ -0,0 +1,198 @@ +package org.wordpress.android.util; + +import android.graphics.Canvas; +import android.graphics.drawable.BitmapDrawable; +import android.graphics.drawable.Drawable; +import android.text.Html; +import android.text.TextUtils; +import android.widget.TextView; + +import com.android.volley.VolleyError; +import com.android.volley.toolbox.ImageLoader; + +import org.wordpress.android.util.AppLog.T; + +import java.lang.ref.WeakReference; + +/** + * ImageGetter for Html.fromHtml() + * adapted from existing ImageGetter code in NoteCommentFragment + */ +public class WPImageGetter implements Html.ImageGetter { + private WeakReference mWeakView; + private int mMaxSize; + private ImageLoader mImageLoader; + private Drawable mLoadingDrawable; + private Drawable mFailedDrawable; + + public WPImageGetter(TextView view) { + this(view, 0); + } + + public WPImageGetter(TextView view, int maxSize) { + mWeakView = new WeakReference(view); + mMaxSize = maxSize; + } + + public WPImageGetter(TextView view, int maxSize, ImageLoader imageLoader, Drawable loadingDrawable, + Drawable failedDrawable) { + mWeakView = new WeakReference(view); + mMaxSize = maxSize; + mImageLoader = imageLoader; + mLoadingDrawable = loadingDrawable; + mFailedDrawable = failedDrawable; + } + + public void setImageLoader(ImageLoader imageLoader) { + mImageLoader = imageLoader; + } + + public void setLoadingDrawable(Drawable loadingDrawable) { + mLoadingDrawable = loadingDrawable; + } + + public void setFailedDrawable(Drawable failedDrawable) { + mFailedDrawable = failedDrawable; + } + + private TextView getView() { + return mWeakView.get(); + } + + @Override + public Drawable getDrawable(String source) { + if (mImageLoader == null || mLoadingDrawable == null || mFailedDrawable == null) { + throw new RuntimeException("Developer, you need to call setImageLoader, setLoadingDrawable and setFailedDrawable"); + } + + if (TextUtils.isEmpty(source)) { + return null; + } + + // images in reader comments may skip "http:" (no idea why) so make sure to add protocol here + if (source.startsWith("//")) { + source = "http:" + source; + } + + // use Photon if a max size is requested (otherwise the full-sized image will be downloaded + // and then resized) + if (mMaxSize > 0) { + source = PhotonUtils.getPhotonImageUrl(source, mMaxSize, 0); + } + + TextView view = getView(); + // Drawable loading = view.getContext().getResources().getDrawable(R.drawable.remote_image); FIXME: here + // Drawable failed = view.getContext().getResources().getDrawable(R.drawable.remote_failed); + final RemoteDrawable remote = new RemoteDrawable(mLoadingDrawable, mFailedDrawable); + + mImageLoader.get(source, new ImageLoader.ImageListener() { + @Override + public void onErrorResponse(VolleyError error) { + remote.displayFailed(); + TextView view = getView(); + if (view != null) { + view.invalidate(); + } + } + + @Override + public void onResponse(ImageLoader.ImageContainer response, boolean isImmediate) { + if (response.getBitmap() != null) { + // make sure view is still valid + TextView view = getView(); + if (view == null) { + AppLog.w(T.UTILS, "WPImageGetter view is invalid"); + return; + } + + Drawable drawable = new BitmapDrawable(view.getContext().getResources(), response.getBitmap()); + final int oldHeight = remote.getBounds().height(); + int maxWidth = view.getWidth() - view.getPaddingLeft() - view.getPaddingRight(); + if (mMaxSize > 0 && (maxWidth > mMaxSize || maxWidth == 0)) { + maxWidth = mMaxSize; + } + remote.setRemoteDrawable(drawable, maxWidth); + + // image is from cache? don't need to modify view height + if (isImmediate) { + return; + } + + int newHeight = remote.getBounds().height(); + view.invalidate(); + // For ICS + view.setHeight(view.getHeight() + newHeight - oldHeight); + // Pre ICS + view.setEllipsize(null); + } + } + }); + return remote; + } + + private static class RemoteDrawable extends BitmapDrawable { + protected Drawable mRemoteDrawable; + protected Drawable mLoadingDrawable; + protected Drawable mFailedDrawable; + private boolean mDidFail = false; + + public RemoteDrawable(Drawable loadingDrawable, Drawable failedDrawable) { + mLoadingDrawable = loadingDrawable; + mFailedDrawable = failedDrawable; + setBounds(0, 0, mLoadingDrawable.getIntrinsicWidth(), mLoadingDrawable.getIntrinsicHeight()); + } + + public void displayFailed() { + mDidFail = true; + } + + public void setBounds(int x, int y, int width, int height) { + super.setBounds(x, y, width, height); + if (mRemoteDrawable != null) { + mRemoteDrawable.setBounds(x, y, width, height); + return; + } + if (mLoadingDrawable != null) { + mLoadingDrawable.setBounds(x, y, width, height); + mFailedDrawable.setBounds(x, y, width, height); + } + } + + public void setRemoteDrawable(Drawable remote) { + mRemoteDrawable = remote; + setBounds(0, 0, mRemoteDrawable.getIntrinsicWidth(), mRemoteDrawable.getIntrinsicHeight()); + } + + public void setRemoteDrawable(Drawable remote, int maxWidth) { + // null sentinel for now + if (remote == null) { + // throw error + return; + } + mRemoteDrawable = remote; + // determine if we need to scale the image to fit in view + int imgWidth = remote.getIntrinsicWidth(); + int imgHeight = remote.getIntrinsicHeight(); + float xScale = (float) imgWidth / (float) maxWidth; + if (xScale > 1.0f) { + setBounds(0, 0, Math.round(imgWidth / xScale), Math.round(imgHeight / xScale)); + } else { + setBounds(0, 0, imgWidth, imgHeight); + } + } + + public boolean didFail() { + return mDidFail; + } + + public void draw(Canvas canvas) { + if (mRemoteDrawable != null) { + mRemoteDrawable.draw(canvas); + } else if (didFail()) { + mFailedDrawable.draw(canvas); + } else { + mLoadingDrawable.draw(canvas); + } + } + } +} diff --git a/WordPressUtils/src/main/java/org/wordpress/android/util/WPQuoteSpan.java b/WordPressUtils/src/main/java/org/wordpress/android/util/WPQuoteSpan.java new file mode 100644 index 000000000000..37d5dfe6dee2 --- /dev/null +++ b/WordPressUtils/src/main/java/org/wordpress/android/util/WPQuoteSpan.java @@ -0,0 +1,44 @@ +package org.wordpress.android.util; + +import android.graphics.Canvas; +import android.graphics.Paint; +import android.text.Layout; +import android.text.style.QuoteSpan; + +/** + * Customzed QuoteSpan for use in SpannableString's + */ +public class WPQuoteSpan extends QuoteSpan { + public static final int STRIPE_COLOR = 0xFF21759B; + private static final int STRIPE_WIDTH = 5; + private static final int GAP_WIDTH = 20; + + public WPQuoteSpan(){ + super(STRIPE_COLOR); + } + + @Override + public int getLeadingMargin(boolean first) { + int margin = GAP_WIDTH * 2 + STRIPE_WIDTH; + return margin; + } + + /** + * Draw a nice thick gray bar if Ice Cream Sandwhich or newer. There's a + * bug on older devices that does not respect the increased margin. + */ + @Override + public void drawLeadingMargin(Canvas c, Paint p, int x, int dir, int top, int baseline, int bottom, + CharSequence text, int start, int end, boolean first, Layout layout) { + Paint.Style style = p.getStyle(); + int color = p.getColor(); + + p.setStyle(Paint.Style.FILL); + p.setColor(STRIPE_COLOR); + + c.drawRect(GAP_WIDTH + x, top, x + dir * STRIPE_WIDTH, bottom, p); + + p.setStyle(style); + p.setColor(color); + } +} diff --git a/WordPressUtils/src/main/java/org/wordpress/android/util/WPWebChromeClient.java b/WordPressUtils/src/main/java/org/wordpress/android/util/WPWebChromeClient.java new file mode 100644 index 000000000000..6a40c6f3807b --- /dev/null +++ b/WordPressUtils/src/main/java/org/wordpress/android/util/WPWebChromeClient.java @@ -0,0 +1,29 @@ +package org.wordpress.android.util; + +import android.app.Activity; +import android.view.View; +import android.webkit.WebChromeClient; +import android.webkit.WebView; +import android.widget.ProgressBar; + +public class WPWebChromeClient extends WebChromeClient { + private ProgressBar mProgressBar; + private Activity mActivity; + + public WPWebChromeClient(Activity activity, ProgressBar progressBar) { + mProgressBar = progressBar; + mActivity = activity; + } + + public void onProgressChanged(WebView webView, int progress) { + if (!mActivity.isFinishing()) { + mActivity.setTitle(webView.getTitle()); + } + if (progress == 100) { + mProgressBar.setVisibility(View.GONE); + } else { + mProgressBar.setVisibility(View.VISIBLE); + mProgressBar.setProgress(progress); + } + } +} \ No newline at end of file diff --git a/WordPressUtils/src/main/java/org/wordpress/android/util/ptr/PullToRefreshHeaderTransformer.java b/WordPressUtils/src/main/java/org/wordpress/android/util/ptr/PullToRefreshHeaderTransformer.java new file mode 100644 index 000000000000..3fec8d91fb8b --- /dev/null +++ b/WordPressUtils/src/main/java/org/wordpress/android/util/ptr/PullToRefreshHeaderTransformer.java @@ -0,0 +1,99 @@ +package org.wordpress.android.util.ptr; + +import android.animation.AnimatorSet; +import android.animation.ObjectAnimator; +import android.app.Activity; +import android.view.View; +import android.view.ViewGroup; +import android.view.animation.Animation; + +import org.wordpress.android.util.R; + +import uk.co.senab.actionbarpulltorefresh.library.DefaultHeaderTransformer; +import uk.co.senab.actionbarpulltorefresh.library.sdk.Compat; + +public class PullToRefreshHeaderTransformer extends DefaultHeaderTransformer { + private View mHeaderView; + private ViewGroup mContentLayout; + private long mAnimationDuration; + private boolean mShowProgressBarOnly; + private Animation mHeaderOutAnimation; + private OnTopScrollChangedListener mOnTopScrollChangedListener; + + public interface OnTopScrollChangedListener { + public void onTopScrollChanged(boolean scrolledOnTop); + } + + public void setShowProgressBarOnly(boolean progressBarOnly) { + mShowProgressBarOnly = progressBarOnly; + } + + @Override + public void onViewCreated(Activity activity, View headerView) { + super.onViewCreated(activity, headerView); + mHeaderView = headerView; + mContentLayout = (ViewGroup) headerView.findViewById(R.id.ptr_content); + mAnimationDuration = activity.getResources().getInteger(android.R.integer.config_shortAnimTime); + } + + @Override + public boolean hideHeaderView() { + mShowProgressBarOnly = false; + return super.hideHeaderView(); + } + + @Override + public boolean showHeaderView() { + // Workaround to avoid this bug https://github.com/chrisbanes/ActionBar-PullToRefresh/issues/265 + // Note, that also remove the alpha animation + resetContentLayoutAlpha(); + + boolean changeVis = mHeaderView.getVisibility() != View.VISIBLE; + mContentLayout.setVisibility(View.VISIBLE); + if (changeVis) { + mHeaderView.setVisibility(View.VISIBLE); + AnimatorSet animSet = new AnimatorSet(); + ObjectAnimator alphaAnim = ObjectAnimator.ofFloat(mHeaderView, "alpha", 0f, 1f); + ObjectAnimator transAnim = ObjectAnimator.ofFloat(mContentLayout, "translationY", + -mContentLayout.getHeight(), 10f); + animSet.playTogether(transAnim, alphaAnim); + animSet.play(alphaAnim); + animSet.setDuration(mAnimationDuration); + animSet.start(); + if (mShowProgressBarOnly) { + mContentLayout.setVisibility(View.INVISIBLE); + } + } + return changeVis; + } + + @Override + public void onPulled(float percentagePulled) { + super.onPulled(percentagePulled); + } + + private void resetContentLayoutAlpha() { + Compat.setAlpha(mContentLayout, 1f); + } + + @Override + public void onReset() { + super.onReset(); + // Reset the Content Layout + if (mContentLayout != null) { + Compat.setAlpha(mContentLayout, 1f); + mContentLayout.setVisibility(View.VISIBLE); + } + } + + @Override + public void onTopScrollChanged(boolean scrolledOnTop) { + if (mOnTopScrollChangedListener != null) { + mOnTopScrollChangedListener.onTopScrollChanged(scrolledOnTop); + } + } + + public void setOnTopScrollChangedListener(OnTopScrollChangedListener listener) { + mOnTopScrollChangedListener = listener; + } +} diff --git a/WordPressUtils/src/main/java/org/wordpress/android/util/ptr/PullToRefreshHelper.java b/WordPressUtils/src/main/java/org/wordpress/android/util/ptr/PullToRefreshHelper.java new file mode 100644 index 000000000000..3c7b4661955d --- /dev/null +++ b/WordPressUtils/src/main/java/org/wordpress/android/util/ptr/PullToRefreshHelper.java @@ -0,0 +1,142 @@ +package org.wordpress.android.util.ptr; + +import android.app.Activity; +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.content.SharedPreferences; +import android.content.SharedPreferences.Editor; +import android.preference.PreferenceManager; +import android.support.v4.content.LocalBroadcastManager; +import android.view.View; + +import org.wordpress.android.util.R; +import org.wordpress.android.util.ToastUtils; +import org.wordpress.android.util.ToastUtils.Duration; + +import java.lang.ref.WeakReference; +import java.util.Arrays; +import java.util.HashSet; +import java.util.Set; + +import uk.co.senab.actionbarpulltorefresh.library.ActionBarPullToRefresh; +import uk.co.senab.actionbarpulltorefresh.library.ActionBarPullToRefresh.SetupWizard; +import uk.co.senab.actionbarpulltorefresh.library.Options; +import uk.co.senab.actionbarpulltorefresh.library.PullToRefreshLayout; +import uk.co.senab.actionbarpulltorefresh.library.listeners.OnRefreshListener; +import uk.co.senab.actionbarpulltorefresh.library.viewdelegates.ViewDelegate; + +public class PullToRefreshHelper implements OnRefreshListener { + public static final String BROADCAST_ACTION_REFRESH_MENU_PRESSED = "REFRESH_MENU_PRESSED"; + private static final String REFRESH_BUTTON_HIT_COUNT = "REFRESH_BUTTON_HIT_COUNT"; + private static final Set TOAST_FREQUENCY = new HashSet(Arrays.asList(1, 5, 10, 20, 40, 80, 160, + 320, 640)); + private PullToRefreshHeaderTransformer mHeaderTransformer; + private PullToRefreshLayout mPullToRefreshLayout; + private RefreshListener mRefreshListener; + private WeakReference mActivityRef; + + public PullToRefreshHelper(Activity activity, PullToRefreshLayout pullToRefreshLayout, RefreshListener listener) { + init(activity, pullToRefreshLayout, listener, null); + } + + public PullToRefreshHelper(Activity activity, PullToRefreshLayout pullToRefreshLayout, RefreshListener listener, + java.lang.Class viewClass) { + init(activity, pullToRefreshLayout, listener, viewClass); + } + + public void init(Activity activity, PullToRefreshLayout pullToRefreshLayout, RefreshListener listener, + java.lang.Class viewClass) { + mActivityRef = new WeakReference(activity); + mRefreshListener = listener; + mPullToRefreshLayout = pullToRefreshLayout; + mHeaderTransformer = new PullToRefreshHeaderTransformer(); + SetupWizard setupWizard = ActionBarPullToRefresh.from(activity).options(Options.create().headerTransformer( + mHeaderTransformer).build()).allChildrenArePullable().listener(this); + if (viewClass != null) { + setupWizard.useViewDelegate(viewClass, new ViewDelegate() { + @Override + public boolean isReadyForPull(View view, float v, float v2) { + return true; + } + } + ); + } + setupWizard.setup(mPullToRefreshLayout); + } + + public void setRefreshing(boolean refreshing) { + mHeaderTransformer.setShowProgressBarOnly(refreshing); + mPullToRefreshLayout.setRefreshing(refreshing); + } + + public boolean isRefreshing() { + return mPullToRefreshLayout.isRefreshing(); + } + + @Override + public void onRefreshStarted(View view) { + mRefreshListener.onRefreshStarted(view); + } + + public interface RefreshListener { + public void onRefreshStarted(View view); + } + + public void setEnabled(boolean enabled) { + mPullToRefreshLayout.setEnabled(enabled); + } + + public void refreshAction() { + Activity activity = mActivityRef.get(); + if (activity == null) { + return; + } + setRefreshing(true); + mRefreshListener.onRefreshStarted(mPullToRefreshLayout); + SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(activity); + int refreshHits = preferences.getInt(REFRESH_BUTTON_HIT_COUNT, 0); + refreshHits += 1; + if (TOAST_FREQUENCY.contains(refreshHits)) { + ToastUtils.showToast(activity, R.string.ptr_tip_message, Duration.LONG); + } + Editor editor = preferences.edit(); + editor.putInt(REFRESH_BUTTON_HIT_COUNT, refreshHits); + editor.commit(); + } + + public void registerReceiver(Context context) { + if (context == null) { + return; + } + IntentFilter filter = new IntentFilter(); + filter.addAction(BROADCAST_ACTION_REFRESH_MENU_PRESSED); + LocalBroadcastManager lbm = LocalBroadcastManager.getInstance(context); + lbm.registerReceiver(mReceiver, filter); + } + + public void unregisterReceiver(Context context) { + if (context == null) { + return; + } + try { + LocalBroadcastManager lbm = LocalBroadcastManager.getInstance(context); + lbm.unregisterReceiver(mReceiver); + } catch (IllegalArgumentException e) { + // exception occurs if receiver already unregistered (safe to ignore) + } + } + + private final BroadcastReceiver mReceiver = new BroadcastReceiver() { + @Override + public void onReceive(Context context, Intent intent) { + if (intent == null || intent.getAction() == null) { + return; + } + if (intent.getAction().equals(BROADCAST_ACTION_REFRESH_MENU_PRESSED)) { + refreshAction(); + } + } + }; +} diff --git a/WordPressUtils/src/main/res/values/strings.xml b/WordPressUtils/src/main/res/values/strings.xml new file mode 100644 index 000000000000..2061ba880c10 --- /dev/null +++ b/WordPressUtils/src/main/res/values/strings.xml @@ -0,0 +1,4 @@ + + + Tip: Pull down to refresh + diff --git a/build.gradle b/build.gradle new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..0087cd3b18659b5577cf6ad3ef61f8eb9416ebba GIT binary patch literal 51348 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

        CflOl1i4(-2^1KXo)&I5QlgjRKFQgM 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 z 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 literal 0 HcmV?d00001 diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 000000000000..2bdda831e5af --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,6 @@ +#Wed Jul 09 11:48:51 CEST 2014 +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-1.11-all.zip diff --git a/gradlew b/gradlew new file mode 100755 index 000000000000..91a7e269e19d --- /dev/null +++ b/gradlew @@ -0,0 +1,164 @@ +#!/usr/bin/env bash + +############################################################################## +## +## Gradle start up script for UN*X +## +############################################################################## + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS="" + +APP_NAME="Gradle" +APP_BASE_NAME=`basename "$0"` + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD="maximum" + +warn ( ) { + echo "$*" +} + +die ( ) { + echo + echo "$*" + echo + exit 1 +} + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +case "`uname`" in + CYGWIN* ) + cygwin=true + ;; + Darwin* ) + darwin=true + ;; + MINGW* ) + msys=true + ;; +esac + +# For Cygwin, ensure paths are in UNIX format before anything is touched. +if $cygwin ; then + [ -n "$JAVA_HOME" ] && JAVA_HOME=`cygpath --unix "$JAVA_HOME"` +fi + +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +PRG="$0" +# Need this for relative symlinks. +while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi +done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" >&- +APP_HOME="`pwd -P`" +cd "$SAVED" >&- + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD="java" + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if [ "$cygwin" = "false" -a "$darwin" = "false" ] ; then + MAX_FD_LIMIT=`ulimit -H -n` + if [ $? -eq 0 ] ; then + if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then + MAX_FD="$MAX_FD_LIMIT" + fi + ulimit -n $MAX_FD + if [ $? -ne 0 ] ; then + warn "Could not set maximum file descriptor limit: $MAX_FD" + fi + else + warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" + fi +fi + +# For Darwin, add options to specify how the application appears in the dock +if $darwin; then + GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" +fi + +# For Cygwin, switch paths to Windows format before running java +if $cygwin ; then + APP_HOME=`cygpath --path --mixed "$APP_HOME"` + CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` + + # We build the pattern for arguments to be converted via cygpath + ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` + SEP="" + for dir in $ROOTDIRSRAW ; do + ROOTDIRS="$ROOTDIRS$SEP$dir" + SEP="|" + done + OURCYGPATTERN="(^($ROOTDIRS))" + # Add a user-defined pattern to the cygpath arguments + if [ "$GRADLE_CYGPATTERN" != "" ] ; then + OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" + fi + # Now convert the arguments - kludge to limit ourselves to /bin/sh + i=0 + for arg in "$@" ; do + CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` + CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option + + if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition + eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` + else + eval `echo args$i`="\"$arg\"" + fi + i=$((i+1)) + done + case $i in + (0) set -- ;; + (1) set -- "$args0" ;; + (2) set -- "$args0" "$args1" ;; + (3) set -- "$args0" "$args1" "$args2" ;; + (4) set -- "$args0" "$args1" "$args2" "$args3" ;; + (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; + (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; + (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; + (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; + (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; + esac +fi + +# Split up the JVM_OPTS And GRADLE_OPTS values into an array, following the shell quoting and substitution rules +function splitJvmOpts() { + JVM_OPTS=("$@") +} +eval splitJvmOpts $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS +JVM_OPTS[${#JVM_OPTS[*]}]="-Dorg.gradle.appname=$APP_BASE_NAME" + +exec "$JAVACMD" "${JVM_OPTS[@]}" -classpath "$CLASSPATH" org.gradle.wrapper.GradleWrapperMain "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 000000000000..8a0b282aa688 --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,90 @@ +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS= + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto init + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto init + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:init +@rem Get command-line arguments, handling Windowz variants + +if not "%OS%" == "Windows_NT" goto win9xME_args +if "%@eval[2+2]" == "4" goto 4NT_args + +:win9xME_args +@rem Slurp the command line arguments. +set CMD_LINE_ARGS= +set _SKIP=2 + +:win9xME_args_slurp +if "x%~1" == "x" goto execute + +set CMD_LINE_ARGS=%* +goto execute + +:4NT_args +@rem Get arguments from the 4NT Shell from JP Software +set CMD_LINE_ARGS=%$ + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/settings.gradle b/settings.gradle new file mode 100644 index 000000000000..3519745edd00 --- /dev/null +++ b/settings.gradle @@ -0,0 +1 @@ +include ':WordPressUtils' \ No newline at end of file From 6dcf01d784a1b28a4fa5cc89f1c18a2ead145840 Mon Sep 17 00:00:00 2001 From: Maxime Biais Date: Tue, 29 Jul 2014 14:13:20 +0200 Subject: [PATCH 26/31] fix #1681: null check --- .gitignore | 25 + WordPressUtils/build.gradle | 55 ++ WordPressUtils/gradle.properties-example | 1 + WordPressUtils/src/main/AndroidManifest.xml | 5 + .../org/wordpress/android/util/AlertUtil.java | 101 ++++ .../org/wordpress/android/util/AppLog.java | 214 +++++++ .../org/wordpress/android/util/BlogUtils.java | 25 + .../wordpress/android/util/DeviceUtils.java | 94 +++ .../wordpress/android/util/DisplayUtils.java | 93 +++ .../wordpress/android/util/EditTextUtils.java | 77 +++ .../org/wordpress/android/util/Emoticons.java | 106 ++++ .../wordpress/android/util/FormatUtils.java | 35 ++ .../wordpress/android/util/GeocoderUtils.java | 116 ++++ .../wordpress/android/util/GravatarUtils.java | 22 + .../org/wordpress/android/util/HtmlUtils.java | 138 +++++ .../wordpress/android/util/ImageUtils.java | 554 ++++++++++++++++++ .../org/wordpress/android/util/JSONUtil.java | 236 ++++++++ .../util/ListScrollPositionManager.java | 36 ++ .../android/util/LocationHelper.java | 132 +++++ .../org/wordpress/android/util/MapUtils.java | 79 +++ .../wordpress/android/util/PhotonUtils.java | 96 +++ .../android/util/ProfilingUtils.java | 91 +++ .../java/org/wordpress/android/util/README.md | 1 + .../org/wordpress/android/util/SqlUtils.java | 121 ++++ .../wordpress/android/util/StringUtils.java | 278 +++++++++ .../android/util/SystemServiceFactory.java | 17 + .../util/SystemServiceFactoryAbstract.java | 7 + .../util/SystemServiceFactoryDefault.java | 9 + .../wordpress/android/util/ToastUtils.java | 37 ++ .../org/wordpress/android/util/UrlUtils.java | 165 ++++++ .../org/wordpress/android/util/UserEmail.java | 35 ++ .../org/wordpress/android/util/Version.java | 47 ++ .../android/util/WPHtmlTagHandler.java | 59 ++ .../wordpress/android/util/WPImageGetter.java | 198 +++++++ .../wordpress/android/util/WPQuoteSpan.java | 44 ++ .../android/util/WPWebChromeClient.java | 29 + .../ptr/PullToRefreshHeaderTransformer.java | 99 ++++ .../android/util/ptr/PullToRefreshHelper.java | 142 +++++ .../src/main/res/values/strings.xml | 4 + build.gradle | 0 gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 51348 bytes gradle/wrapper/gradle-wrapper.properties | 6 + gradlew | 164 ++++++ gradlew.bat | 90 +++ settings.gradle | 1 + 45 files changed, 3884 insertions(+) create mode 100644 .gitignore create mode 100644 WordPressUtils/build.gradle create mode 100644 WordPressUtils/gradle.properties-example create mode 100644 WordPressUtils/src/main/AndroidManifest.xml create mode 100644 WordPressUtils/src/main/java/org/wordpress/android/util/AlertUtil.java create mode 100644 WordPressUtils/src/main/java/org/wordpress/android/util/AppLog.java create mode 100644 WordPressUtils/src/main/java/org/wordpress/android/util/BlogUtils.java create mode 100644 WordPressUtils/src/main/java/org/wordpress/android/util/DeviceUtils.java create mode 100644 WordPressUtils/src/main/java/org/wordpress/android/util/DisplayUtils.java create mode 100644 WordPressUtils/src/main/java/org/wordpress/android/util/EditTextUtils.java create mode 100644 WordPressUtils/src/main/java/org/wordpress/android/util/Emoticons.java create mode 100644 WordPressUtils/src/main/java/org/wordpress/android/util/FormatUtils.java create mode 100644 WordPressUtils/src/main/java/org/wordpress/android/util/GeocoderUtils.java create mode 100644 WordPressUtils/src/main/java/org/wordpress/android/util/GravatarUtils.java create mode 100644 WordPressUtils/src/main/java/org/wordpress/android/util/HtmlUtils.java create mode 100644 WordPressUtils/src/main/java/org/wordpress/android/util/ImageUtils.java create mode 100644 WordPressUtils/src/main/java/org/wordpress/android/util/JSONUtil.java create mode 100644 WordPressUtils/src/main/java/org/wordpress/android/util/ListScrollPositionManager.java create mode 100644 WordPressUtils/src/main/java/org/wordpress/android/util/LocationHelper.java create mode 100644 WordPressUtils/src/main/java/org/wordpress/android/util/MapUtils.java create mode 100644 WordPressUtils/src/main/java/org/wordpress/android/util/PhotonUtils.java create mode 100644 WordPressUtils/src/main/java/org/wordpress/android/util/ProfilingUtils.java create mode 100644 WordPressUtils/src/main/java/org/wordpress/android/util/README.md create mode 100644 WordPressUtils/src/main/java/org/wordpress/android/util/SqlUtils.java create mode 100644 WordPressUtils/src/main/java/org/wordpress/android/util/StringUtils.java create mode 100644 WordPressUtils/src/main/java/org/wordpress/android/util/SystemServiceFactory.java create mode 100644 WordPressUtils/src/main/java/org/wordpress/android/util/SystemServiceFactoryAbstract.java create mode 100644 WordPressUtils/src/main/java/org/wordpress/android/util/SystemServiceFactoryDefault.java create mode 100644 WordPressUtils/src/main/java/org/wordpress/android/util/ToastUtils.java create mode 100644 WordPressUtils/src/main/java/org/wordpress/android/util/UrlUtils.java create mode 100644 WordPressUtils/src/main/java/org/wordpress/android/util/UserEmail.java create mode 100644 WordPressUtils/src/main/java/org/wordpress/android/util/Version.java create mode 100644 WordPressUtils/src/main/java/org/wordpress/android/util/WPHtmlTagHandler.java create mode 100644 WordPressUtils/src/main/java/org/wordpress/android/util/WPImageGetter.java create mode 100644 WordPressUtils/src/main/java/org/wordpress/android/util/WPQuoteSpan.java create mode 100644 WordPressUtils/src/main/java/org/wordpress/android/util/WPWebChromeClient.java create mode 100644 WordPressUtils/src/main/java/org/wordpress/android/util/ptr/PullToRefreshHeaderTransformer.java create mode 100644 WordPressUtils/src/main/java/org/wordpress/android/util/ptr/PullToRefreshHelper.java create mode 100644 WordPressUtils/src/main/res/values/strings.xml create mode 100644 build.gradle create mode 100644 gradle/wrapper/gradle-wrapper.jar create mode 100644 gradle/wrapper/gradle-wrapper.properties create mode 100755 gradlew create mode 100644 gradlew.bat create mode 100644 settings.gradle diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000000..8babf679a8c4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,25 @@ +# generated files +build/ + +# Local configuration file (sdk path, etc) +local.properties +tools/deploy-mvn-artifact.conf + +# Intellij project files +*.iml +*.ipr +*.iws +.idea/ + +# Gradle +.gradle/ +gradle.properties + +# Idea +.idea/workspace.xml +*.iml + +# OS X +.DS_Store + +# dependencies diff --git a/WordPressUtils/build.gradle b/WordPressUtils/build.gradle new file mode 100644 index 000000000000..da68c80de30e --- /dev/null +++ b/WordPressUtils/build.gradle @@ -0,0 +1,55 @@ + +buildscript { + repositories { + mavenCentral() + } + dependencies { + classpath 'com.android.tools.build:gradle:0.12.+' + } +} + +apply plugin: 'com.android.library' +apply plugin: 'maven' + +repositories { + mavenCentral() + maven { url 'http://wordpress-mobile.github.io/WordPress-Android' } +} + +dependencies { + compile 'commons-lang:commons-lang:2.6' + compile 'com.mcxiaoke.volley:library:1.0.+' + compile 'com.github.castorflex.smoothprogressbar:library:0.4.0' + compile 'org.wordpress:pulltorefresh-main:+@aar' // org.wordpress version includes some fixes + compile 'com.android.support:support-v13:19.0.+' +} + +android { + defaultPublishConfig 'debug' + + compileSdkVersion 19 + buildToolsVersion "19.1.0" + + defaultConfig { + applicationId "org.wordpress.android.util" + versionName "1.0.2" + versionCode 1 + minSdkVersion 14 + targetSdkVersion 19 + } +} + +uploadArchives { + repositories { + mavenDeployer { + def repo_url = "" + if (project.hasProperty("repository")) { + repo_url = project.repository + } + repository(url: repo_url) + pom.version = android.defaultConfig.versionName + pom.groupId = "org.wordpress" + pom.artifactId = "wordpress-utils" + } + } +} diff --git a/WordPressUtils/gradle.properties-example b/WordPressUtils/gradle.properties-example new file mode 100644 index 000000000000..36ceb8db22bc --- /dev/null +++ b/WordPressUtils/gradle.properties-example @@ -0,0 +1 @@ +repository=file:///Users/max/work/automattic/WordPress-Android-gh-pages/ diff --git a/WordPressUtils/src/main/AndroidManifest.xml b/WordPressUtils/src/main/AndroidManifest.xml new file mode 100644 index 000000000000..4f3bd125a3c5 --- /dev/null +++ b/WordPressUtils/src/main/AndroidManifest.xml @@ -0,0 +1,5 @@ + + + + diff --git a/WordPressUtils/src/main/java/org/wordpress/android/util/AlertUtil.java b/WordPressUtils/src/main/java/org/wordpress/android/util/AlertUtil.java new file mode 100644 index 000000000000..76800de4cd6d --- /dev/null +++ b/WordPressUtils/src/main/java/org/wordpress/android/util/AlertUtil.java @@ -0,0 +1,101 @@ +/* + * Copyright (C) 2011 wordpress.org + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package org.wordpress.android.util; + +import android.app.AlertDialog; +import android.app.Dialog; +import android.content.Context; +import android.content.DialogInterface; + +public class AlertUtil { + /** + * Show Alert Dialog + * @param context + * @param titleId + * @param messageId + */ + public static void showAlert(Context context, int titleId, int messageId) { + Dialog dlg = new AlertDialog.Builder(context) + .setTitle(titleId) + .setPositiveButton(android.R.string.ok, null) + .setMessage(messageId) + .create(); + + dlg.show(); + } + + /** + * Show Alert Dialog + * @param context + * @param titleId + * @param messageId + */ + public static void showAlert(Context context, int titleId, String message) { + Dialog dlg = new AlertDialog.Builder(context) + .setTitle(titleId) + .setPositiveButton(android.R.string.ok, null) + .setMessage(message) + .create(); + + dlg.show(); + } + + /** + * Show Alert Dialog + * @param context + * @param titleId + * @param messageId + * @param positiveButtontxt + * @param positiveListener + * @param negativeButtontxt + * @param negativeListener + */ + public static void showAlert(Context context, int titleId, int messageId, + CharSequence positiveButtontxt, DialogInterface.OnClickListener positiveListener, + CharSequence negativeButtontxt, DialogInterface.OnClickListener negativeListener) { + Dialog dlg = new AlertDialog.Builder(context) + .setTitle(titleId) + .setPositiveButton(positiveButtontxt, positiveListener) + .setNegativeButton(negativeButtontxt, negativeListener) + .setMessage(messageId) + .setCancelable(false) + .create(); + + dlg.show(); + } + + /** + * Show Alert Dialog + * @param context + * @param titleId + * @param messageId + * @param positiveButtontxt + * @param positiveListener + */ + public static void showAlert(Context context, int titleId, String message, + CharSequence positiveButtontxt, DialogInterface.OnClickListener positiveListener) { + Dialog dlg = new AlertDialog.Builder(context) + .setTitle(titleId) + .setPositiveButton(positiveButtontxt, positiveListener) + .setMessage(message) + .setCancelable(false) + .create(); + + dlg.show(); + } +} + diff --git a/WordPressUtils/src/main/java/org/wordpress/android/util/AppLog.java b/WordPressUtils/src/main/java/org/wordpress/android/util/AppLog.java new file mode 100644 index 000000000000..f2fff1b2ef4a --- /dev/null +++ b/WordPressUtils/src/main/java/org/wordpress/android/util/AppLog.java @@ -0,0 +1,214 @@ +package org.wordpress.android.util; + +import android.content.Context; +import android.text.TextUtils; +import android.util.Log; + +import java.io.PrintWriter; +import java.io.StringWriter; +import java.util.ArrayList; +import java.util.Iterator; +import java.util.NoSuchElementException; + +/** + * simple wrapper for Android log calls, enables recording & displaying log + */ +public class AppLog { + // T for Tag + public enum T {READER, EDITOR, MEDIA, NUX, API, STATS, UTILS, NOTIFS, DB, POSTS, COMMENTS, THEMES, TESTS, PROFILING, SIMPERIUM} + public static final String TAG = "WordPress"; + + private static boolean mEnableRecording = false; + + private AppLog() { + throw new AssertionError(); + } + + /* + * defaults to false, pass true to capture log so it can be displayed by AppLogViewerActivity + */ + public static void enableRecording(boolean enable) { + mEnableRecording = enable; + } + + public static void v(T tag, String message) { + Log.v(TAG + "-" + tag.toString(), message); + addEntry(tag, LogLevel.v, message); + } + + public static void d(T tag, String message) { + Log.d(TAG + "-" + tag.toString(), message); + addEntry(tag, LogLevel.d, message); + } + + public static void i(T tag, String message) { + Log.i(TAG + "-" + tag.toString(), message); + addEntry(tag, LogLevel.i, message); + } + + public static void w(T tag, String message) { + Log.w(TAG + "-" + tag.toString(), message); + addEntry(tag, LogLevel.w, message); + } + + public static void e(T tag, String message) { + Log.e(TAG + "-" + tag.toString(), message); + addEntry(tag, LogLevel.e, message); + } + + public static void e(T tag, String message, Throwable tr) { + Log.e(TAG + "-" + tag.toString(), message, tr); + addEntry(tag, LogLevel.e, message + " - exception: " + tr.getMessage()); + addEntry(tag, LogLevel.e, "StackTrace: " + getHTMLStringStackTrace(tr)); + } + + public static void e(T tag, Throwable tr) { + Log.e(TAG + "-" + tag.toString(), tr.getMessage(), tr); + addEntry(tag, LogLevel.e, tr.getMessage()); + addEntry(tag, LogLevel.e, "StackTrace: " + getHTMLStringStackTrace(tr)); + } + + public static void e(T tag, String volleyErrorMsg, int statusCode) { + if (TextUtils.isEmpty(volleyErrorMsg)) { + return; + } + String logText; + if (statusCode == -1) { + logText = volleyErrorMsg; + } else { + logText = volleyErrorMsg + ", status " + statusCode; + } + Log.e(TAG + "-" + tag.toString(), logText); + addEntry(tag, LogLevel.w, logText); + } + + // -------------------------------------------------------------------------------------------------------- + + private static final int MAX_ENTRIES = 99; + + private enum LogLevel { + v, d, i, w, e; + private String toHtmlColor() { + switch(this) { + case v: + return "grey"; + case i: + return "black"; + case w: + return "purple"; + case e: + return "red"; + case d: + default: + return "teal"; + } + } + } + + private static class LogEntry { + LogLevel logLevel; + String logText; + T logTag; + + private String toHtml() { + StringBuilder sb = new StringBuilder() + .append("") + .append("[") + .append(logTag.name()) + .append("] ") + .append(logLevel.name()) + .append(": ") + .append(logText) + .append(""); + return sb.toString(); + } + } + + private static class LogEntryList extends ArrayList { + private synchronized boolean addEntry(LogEntry entry) { + if (size() >= MAX_ENTRIES) + removeFirstEntry(); + return add(entry); + } + private void removeFirstEntry() { + Iterator it = iterator(); + if (!it.hasNext()) + return; + try { + remove(it.next()); + } catch (NoSuchElementException e) { + // ignore + } + } + } + + private static LogEntryList mLogEntries = new LogEntryList(); + + private static void addEntry(T tag, LogLevel level, String text) { + // skip if recording is disabled (default) + if (!mEnableRecording) + return; + LogEntry entry = new LogEntry(); + entry.logLevel = level; + entry.logText = text; + entry.logTag = tag; + mLogEntries.addEntry(entry); + } + + private static String getStringStackTrace(Throwable throwable) { + StringWriter errors = new StringWriter(); + throwable.printStackTrace(new PrintWriter(errors)); + return errors.toString(); + } + + private static String getHTMLStringStackTrace(Throwable throwable) { + return getStringStackTrace(throwable).replace("\n", "
        "); + } + + /* + * returns entire log as html for display (see AppLogViewerActivity) + */ + public static String toHtml(Context context) { + StringBuilder sb = new StringBuilder(); + + // add version & device info + sb.append("WordPress Android version: " + ProfilingUtils.getVersionName(context)).append("
        ") + .append("Android device name: " + DeviceUtils.getInstance().getDeviceName(context)).append("
        "); + + Iterator it = mLogEntries.iterator(); + int lineNum = 1; + while (it.hasNext()) { + sb.append("") + .append(String.format("%02d", lineNum)) + .append(" ") + .append(it.next().toHtml()) + .append("
        "); + lineNum++; + } + return sb.toString(); + } + + + /* + * returns entire log as plain text + */ + public static String toPlainText(Context context) { + StringBuilder sb = new StringBuilder(); + + // add version & device info + sb.append("WordPress Android version: " + ProfilingUtils.getVersionName(context)).append("\n") + .append("Android device name: " + DeviceUtils.getInstance().getDeviceName(context)).append("\n\n"); + + Iterator it = mLogEntries.iterator(); + int lineNum = 1; + while (it.hasNext()) { + sb.append(String.format("%02d - ", lineNum)) + .append(it.next().logText) + .append("\n"); + lineNum++; + } + return sb.toString(); + } +} diff --git a/WordPressUtils/src/main/java/org/wordpress/android/util/BlogUtils.java b/WordPressUtils/src/main/java/org/wordpress/android/util/BlogUtils.java new file mode 100644 index 000000000000..166085a4f066 --- /dev/null +++ b/WordPressUtils/src/main/java/org/wordpress/android/util/BlogUtils.java @@ -0,0 +1,25 @@ +package org.wordpress.android.util; + +import java.util.Comparator; +import java.util.Map; + +public class BlogUtils { + public static Comparator BlogNameComparator = new Comparator() { + public int compare(Object blog1, Object blog2) { + Map blogMap1 = (Map) blog1; + Map blogMap2 = (Map) blog2; + + String blogName1 = MapUtils.getMapStr(blogMap1, "blogName"); + if (blogName1.length() == 0) { + blogName1 = MapUtils.getMapStr(blogMap1, "url"); + } + + String blogName2 = MapUtils.getMapStr(blogMap2, "blogName"); + if (blogName2.length() == 0) { + blogName2 = MapUtils.getMapStr(blogMap2, "url"); + } + + return blogName1.compareToIgnoreCase(blogName2); + } + }; +} \ No newline at end of file diff --git a/WordPressUtils/src/main/java/org/wordpress/android/util/DeviceUtils.java b/WordPressUtils/src/main/java/org/wordpress/android/util/DeviceUtils.java new file mode 100644 index 000000000000..639d5479c301 --- /dev/null +++ b/WordPressUtils/src/main/java/org/wordpress/android/util/DeviceUtils.java @@ -0,0 +1,94 @@ +package org.wordpress.android.util; + +import android.content.Context; +import android.content.pm.PackageManager; +import android.os.Build; + +import org.wordpress.android.util.AppLog.T; + +import java.io.IOException; +import java.io.InputStream; +import java.util.Properties; + +public class DeviceUtils { + private static DeviceUtils instance; + private boolean isKindleFire = false; + + public boolean isKindleFire() { + return isKindleFire; + } + + public static DeviceUtils getInstance() { + if (instance == null) { + instance = new DeviceUtils(); + } + return instance; + } + + private DeviceUtils() { + isKindleFire = android.os.Build.MODEL.equalsIgnoreCase("kindle fire") ? true: false; + } + + /** + * Checks camera availability recursively based on API level. + * + * TODO: change "android.hardware.camera.front" and "android.hardware.camera.any" to + * {@link PackageManager#FEATURE_CAMERA_FRONT} and {@link PackageManager#FEATURE_CAMERA_ANY}, + * respectively, once they become accessible or minSdk version is incremented. + * + * @param context The context. + * @return Whether camera is available. + */ + public boolean hasCamera(Context context) { + final PackageManager pm = context.getPackageManager(); + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.JELLY_BEAN_MR1) { + return pm.hasSystemFeature(PackageManager.FEATURE_CAMERA) + || pm.hasSystemFeature("android.hardware.camera.front"); + } + + return pm.hasSystemFeature("android.hardware.camera.any"); + } + + public String getDeviceName(Context context) { + String manufacturer = Build.MANUFACTURER; + String undecodedModel = Build.MODEL; + String model = null; + + try { + Properties prop = new Properties(); + InputStream fileStream; + // Read the device name from a precomplied list: + // see http://making.meetup.com/post/29648976176/human-readble-android-device-names + fileStream = context.getAssets().open("android_models.properties"); + prop.load(fileStream); + fileStream.close(); + String decodedModel = prop.getProperty(undecodedModel.replaceAll(" ", "_")); + if (decodedModel != null && !decodedModel.trim().equals("")) { + model = decodedModel; + } + } catch (IOException e) { + AppLog.e(T.UTILS, e.getMessage()); + } + + if (model == null) { //Device model not found in the list + if (undecodedModel.startsWith(manufacturer)) { + model = capitalize(undecodedModel); + } else { + model = capitalize(manufacturer) + " " + undecodedModel; + } + } + return model; + } + + private String capitalize(String s) { + if (s == null || s.length() == 0) { + return ""; + } + char first = s.charAt(0); + if (Character.isUpperCase(first)) { + return s; + } else { + return Character.toUpperCase(first) + s.substring(1); + } + } +} diff --git a/WordPressUtils/src/main/java/org/wordpress/android/util/DisplayUtils.java b/WordPressUtils/src/main/java/org/wordpress/android/util/DisplayUtils.java new file mode 100644 index 000000000000..f64527e9a622 --- /dev/null +++ b/WordPressUtils/src/main/java/org/wordpress/android/util/DisplayUtils.java @@ -0,0 +1,93 @@ +package org.wordpress.android.util; + +import android.content.Context; +import android.content.res.Configuration; +import android.graphics.Point; +import android.util.DisplayMetrics; +import android.util.TypedValue; +import android.view.Display; +import android.view.Window; +import android.view.WindowManager; + +public class DisplayUtils { + private DisplayUtils() { + throw new AssertionError(); + } + + public static boolean isLandscape(Context context) { + if (context == null) + return false; + return context.getResources().getConfiguration().orientation == Configuration.ORIENTATION_LANDSCAPE; + } + + public static boolean isLandscapeTablet(Context context) { + return isLandscape(context) && isTablet(context); + } + + public static Point getDisplayPixelSize(Context context) { + WindowManager wm = (WindowManager) context.getSystemService(Context.WINDOW_SERVICE); + Display display = wm.getDefaultDisplay(); + Point size = new Point(); + display.getSize(size); + return size; + } + + public static int getDisplayPixelWidth(Context context) { + Point size = getDisplayPixelSize(context); + return (size.x); + } + + public static int getDisplayPixelHeight(Context context) { + Point size = getDisplayPixelSize(context); + return (size.y); + } + + public static int dpToPx(Context context, int dp) { + float px = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, dp, context.getResources().getDisplayMetrics()); + return (int) px; + } + + public static int pxToDp(Context context, int px) { + DisplayMetrics displayMetrics = context.getResources().getDisplayMetrics(); + return (int) ((px/displayMetrics.density)+0.5); + } + + public static boolean isTablet(Context context) { + // http://stackoverflow.com/a/8427523/1673548 + if (context == null) + return false; + return (context.getResources().getConfiguration().screenLayout & Configuration.SCREENLAYOUT_SIZE_MASK) >= Configuration.SCREENLAYOUT_SIZE_LARGE; + } + + public static boolean isXLarge(Context context) { + if ((context.getResources().getConfiguration().screenLayout & Configuration.SCREENLAYOUT_SIZE_MASK) == Configuration.SCREENLAYOUT_SIZE_XLARGE) + return true; + return false; + } + + /** + * returns the height of the ActionBar if one is enabled - supports both the native ActionBar + * and ActionBarSherlock - http://stackoverflow.com/a/15476793/1673548 + */ + public static int getActionBarHeight(Context context) { + if (context == null) { + return 0; + } + TypedValue tv = new TypedValue(); + if (context.getTheme() != null + && context.getTheme().resolveAttribute(android.R.attr.actionBarSize, tv, true)) { + return TypedValue.complexToDimensionPixelSize(tv.data, context.getResources().getDisplayMetrics()); + } + + // if we get this far, it's because the device doesn't support an ActionBar, + // so return the standard ActionBar height (48dp) + return dpToPx(context, 48); + } + + /** + * detect when FEATURE_ACTION_BAR_OVERLAY has been set + */ + public static boolean hasActionBarOverlay(Window window) { + return window.hasFeature(Window.FEATURE_ACTION_BAR_OVERLAY); + } +} diff --git a/WordPressUtils/src/main/java/org/wordpress/android/util/EditTextUtils.java b/WordPressUtils/src/main/java/org/wordpress/android/util/EditTextUtils.java new file mode 100644 index 000000000000..64ee67e566a9 --- /dev/null +++ b/WordPressUtils/src/main/java/org/wordpress/android/util/EditTextUtils.java @@ -0,0 +1,77 @@ +package org.wordpress.android.util; + +import android.content.Context; +import android.text.TextUtils; +import android.view.inputmethod.InputMethodManager; +import android.widget.EditText; + +/** + * EditText utils + */ +public class EditTextUtils { + private EditTextUtils() { + throw new AssertionError(); + } + + /** + * returns text string from passed EditText + */ + public static String getText(EditText edit) { + if (edit.getText() == null) { + return ""; + } + return edit.getText().toString(); + } + + /** + * moves caret to end of text + */ + public static void moveToEnd(EditText edit) { + if (edit.getText() == null) { + return; + } + edit.setSelection(edit.getText().toString().length()); + } + + /** + * returns true if nothing has been entered into passed editor + */ + public static boolean isEmpty(EditText edit) { + return TextUtils.isEmpty(getText(edit)); + } + + /** + * hide the soft keyboard for the passed EditText + */ + public static void hideSoftInput(EditText edit) { + if (edit == null) { + return; + } + + InputMethodManager imm = getInputMethodManager(edit); + if (imm != null) { + imm.hideSoftInputFromWindow(edit.getWindowToken(), 0); + } + } + + /** + * show the soft keyboard for the passed EditText + */ + public static void showSoftInput(EditText edit) { + if (edit == null) { + return; + } + + edit.requestFocus(); + + InputMethodManager imm = getInputMethodManager(edit); + if (imm != null) { + imm.showSoftInput(edit, InputMethodManager.SHOW_IMPLICIT); + } + } + + private static InputMethodManager getInputMethodManager(EditText edit) { + Context context = edit.getContext(); + return (InputMethodManager) context.getSystemService(Context.INPUT_METHOD_SERVICE); + } +} diff --git a/WordPressUtils/src/main/java/org/wordpress/android/util/Emoticons.java b/WordPressUtils/src/main/java/org/wordpress/android/util/Emoticons.java new file mode 100644 index 000000000000..5a7566a967cf --- /dev/null +++ b/WordPressUtils/src/main/java/org/wordpress/android/util/Emoticons.java @@ -0,0 +1,106 @@ +package org.wordpress.android.util; + +import android.text.Html; +import android.text.SpannableStringBuilder; +import android.text.Spanned; +import android.text.style.ForegroundColorSpan; +import android.text.style.ImageSpan; +import android.util.SparseArray; + +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +import static android.os.Build.VERSION.SDK_INT; +import static android.os.Build.VERSION_CODES; + +public class Emoticons { + public static final int EMOTICON_COLOR = 0xFF21759B; + private static final boolean HAS_EMOJI = SDK_INT >= VERSION_CODES.JELLY_BEAN; + private static final Map wpSmilies; + public static final SparseArray wpSmiliesCodePointToText; + + static { + Map smilies = new HashMap(); + smilies.put("icon_mrgreen.gif", HAS_EMOJI ? "\uD83D\uDE00" : ":mrgreen:" ); + smilies.put("icon_neutral.gif", HAS_EMOJI ? "\uD83D\uDE14" : ":|" ); + smilies.put("icon_twisted.gif", HAS_EMOJI ? "\uD83D\uDE16" : ":twisted:" ); + smilies.put("icon_arrow.gif", HAS_EMOJI ? "\u27A1" : ":arrow:" ); + smilies.put("icon_eek.gif", HAS_EMOJI ? "\uD83D\uDE32" : "8-O" ); + smilies.put("icon_smile.gif", HAS_EMOJI ? "\uD83D\uDE0A" : ":)" ); + smilies.put("icon_confused.gif", HAS_EMOJI ? "\uD83D\uDE15" : ":?" ); + smilies.put("icon_cool.gif", HAS_EMOJI ? "\uD83D\uDE0A" : "8)" ); + smilies.put("icon_evil.gif", HAS_EMOJI ? "\uD83D\uDE21" : ":evil:" ); + smilies.put("icon_biggrin.gif", HAS_EMOJI ? "\uD83D\uDE03" : ":D" ); + smilies.put("icon_idea.gif", HAS_EMOJI ? "\uD83D\uDCA1" : ":idea:" ); + smilies.put("icon_redface.gif", HAS_EMOJI ? "\uD83D\uDE33" : ":oops:" ); + smilies.put("icon_razz.gif", HAS_EMOJI ? "\uD83D\uDE1D" : ":P" ); + smilies.put("icon_rolleyes.gif", HAS_EMOJI ? "\uD83D\uDE0F" : ":roll:" ); + smilies.put("icon_wink.gif", HAS_EMOJI ? "\uD83D\uDE09" : ";)" ); + smilies.put("icon_cry.gif", HAS_EMOJI ? "\uD83D\uDE22" : ":'(" ); + smilies.put("icon_surprised.gif", HAS_EMOJI ? "\uD83D\uDE32" : ":o" ); + smilies.put("icon_lol.gif", HAS_EMOJI ? "\uD83D\uDE03" : ":lol:" ); + smilies.put("icon_mad.gif", HAS_EMOJI ? "\uD83D\uDE21" : ":x" ); + smilies.put("icon_sad.gif", HAS_EMOJI ? "\uD83D\uDE1E" : ":(" ); + smilies.put("icon_exclaim.gif", HAS_EMOJI ? "\u2757" : ":!:" ); + smilies.put("icon_question.gif", HAS_EMOJI ? "\u2753" : ":?:" ); + + wpSmilies = Collections.unmodifiableMap(smilies); + + wpSmiliesCodePointToText = new SparseArray(20); + wpSmiliesCodePointToText.put(10145, ":arrow:"); + wpSmiliesCodePointToText.put(128161, ":idea:"); + wpSmiliesCodePointToText.put(128512, ":mrgreen:"); + wpSmiliesCodePointToText.put(128515, ":D"); + wpSmiliesCodePointToText.put(128522, ":)"); + wpSmiliesCodePointToText.put(128521, ";)"); + wpSmiliesCodePointToText.put(128532, ":|"); + wpSmiliesCodePointToText.put(128533, ":?"); + wpSmiliesCodePointToText.put(128534, ":twisted:"); + wpSmiliesCodePointToText.put(128542, ":("); + wpSmiliesCodePointToText.put(128545, ":evil:"); + wpSmiliesCodePointToText.put(128546, ":'("); + wpSmiliesCodePointToText.put(128562, ":o"); + wpSmiliesCodePointToText.put(128563, ":oops:"); + wpSmiliesCodePointToText.put(128527, ":roll:"); + wpSmiliesCodePointToText.put(10071, ":!:"); + wpSmiliesCodePointToText.put(10067, ":?:"); + } + + public static String lookupImageSmiley(String url){ + return lookupImageSmiley(url, ""); + } + + public static String lookupImageSmiley(String url, String ifNone){ + String file = url.substring(url.lastIndexOf("/") + 1); + if (wpSmilies.containsKey(file)) { + return wpSmilies.get(file); + } + return ifNone; + } + + public static Spanned replaceEmoticonsWithEmoji(SpannableStringBuilder html){ + ImageSpan imgs[] = html.getSpans(0, html.length(), ImageSpan.class); + for (ImageSpan img : imgs) { + String emoticon = Emoticons.lookupImageSmiley(img.getSource()); + if (!emoticon.equals("")) { + int start = html.getSpanStart(img); + html.replace(start, html.getSpanEnd(img), emoticon); + html.setSpan(new ForegroundColorSpan(EMOTICON_COLOR), start, + start + emoticon.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE); + html.removeSpan(img); + } + } + return html; + } + + public static String replaceEmoticonsWithEmoji(final String text) { + if (text != null && text.contains("icon_")) { + final SpannableStringBuilder html = (SpannableStringBuilder)replaceEmoticonsWithEmoji((SpannableStringBuilder) Html.fromHtml(text)); + // Html.toHtml() is used here rather than toString() since the latter strips html + return Html.toHtml(html); + } else { + return text; + } + } +} \ No newline at end of file diff --git a/WordPressUtils/src/main/java/org/wordpress/android/util/FormatUtils.java b/WordPressUtils/src/main/java/org/wordpress/android/util/FormatUtils.java new file mode 100644 index 000000000000..28282ed5fadf --- /dev/null +++ b/WordPressUtils/src/main/java/org/wordpress/android/util/FormatUtils.java @@ -0,0 +1,35 @@ +package org.wordpress.android.util; + +import java.text.DecimalFormat; +import java.text.NumberFormat; + +public class FormatUtils { + /* + * NumberFormat isn't synchronized, so a separate instance must be created for each thread + * http://developer.android.com/reference/java/text/NumberFormat.html + */ + private static final ThreadLocal IntegerInstance = new ThreadLocal() { + @Override + protected NumberFormat initialValue() { + return NumberFormat.getIntegerInstance(); + } + }; + + private static final ThreadLocal DecimalInstance = new ThreadLocal() { + @Override + protected DecimalFormat initialValue() { + return (DecimalFormat) DecimalFormat.getInstance(); + } + }; + + /* + * returns the passed integer formatted with thousands-separators based on the current locale + */ + public static final String formatInt(int value) { + return IntegerInstance.get().format(value).toString(); + } + + public static final String formatDecimal(int value) { + return DecimalInstance.get().format(value).toString(); + } +} diff --git a/WordPressUtils/src/main/java/org/wordpress/android/util/GeocoderUtils.java b/WordPressUtils/src/main/java/org/wordpress/android/util/GeocoderUtils.java new file mode 100644 index 000000000000..e861a88b8a88 --- /dev/null +++ b/WordPressUtils/src/main/java/org/wordpress/android/util/GeocoderUtils.java @@ -0,0 +1,116 @@ +package org.wordpress.android.util; + +import android.content.Context; +import android.location.Address; +import android.location.Geocoder; + +import java.io.IOException; +import java.util.List; +import java.util.Locale; + +public final class GeocoderUtils { + private GeocoderUtils() { + throw new AssertionError(); + } + + public static Geocoder getGeocoder(Context context) { + // first make sure a Geocoder service exists on this device (requires API 9) + if (!Geocoder.isPresent()) { + return null; + } + + Geocoder gcd; + + try { + gcd = new Geocoder(context, Locale.getDefault()); + } catch (NullPointerException cannotIstantiateEx) { + AppLog.e(AppLog.T.UTILS, "Cannot instantiate Geocoder", cannotIstantiateEx); + return null; + } + + return gcd; + } + + public static Address getAddressFromCoords(Context context, double latitude, double longitude) { + Address address = null; + List
        addresses = null; + + Geocoder gcd = getGeocoder(context); + + if (gcd == null) { + return null; + } + + try { + addresses = gcd.getFromLocation(latitude, longitude, 1); + } catch (IOException e) { + // may get "Unable to parse response from server" IOException here if Geocoder + // service is hit too frequently + AppLog.e(AppLog.T.UTILS, + "Unable to parse response from server. Is Geocoder service hitting the server too frequently?", + e + ); + } + + // addresses may be null or empty if network isn't connected + if (addresses != null && addresses.size() > 0) { + address = addresses.get(0); + } + + return address; + } + + public static Address getAddressFromLocationName(Context context, String locationName) { + int maxResults = 1; + Address address = null; + List
        addresses = null; + + Geocoder gcd = getGeocoder(context); + + if (gcd == null) { + return null; + } + + try { + addresses = gcd.getFromLocationName(locationName, maxResults); + } catch (IOException e) { + AppLog.e(AppLog.T.UTILS, "Failed to get coordinates from location", e); + } + + // addresses may be null or empty if network isn't connected + if (addresses != null && addresses.size() > 0) { + address = addresses.get(0); + } + + return address; + } + + public static String getLocationNameFromAddress(Address address) { + String locality = "", adminArea = "", country = ""; + if (address.getLocality() != null) { + locality = address.getLocality(); + } + + if (address.getAdminArea() != null) { + adminArea = address.getAdminArea(); + } + + if (address.getCountryName() != null) { + country = address.getCountryName(); + } + + return ((locality.equals("")) ? locality : locality + ", ") + + ((adminArea.equals("")) ? adminArea : adminArea + " ") + country; + } + + public static double[] getCoordsFromAddress(Address address) { + double[] coordinates = new double[2]; + + if (address.hasLatitude() && address.hasLongitude()) { + coordinates[0] = address.getLatitude(); + coordinates[1] = address.getLongitude(); + } + + return coordinates; + } +} diff --git a/WordPressUtils/src/main/java/org/wordpress/android/util/GravatarUtils.java b/WordPressUtils/src/main/java/org/wordpress/android/util/GravatarUtils.java new file mode 100644 index 000000000000..c10ce69c81e8 --- /dev/null +++ b/WordPressUtils/src/main/java/org/wordpress/android/util/GravatarUtils.java @@ -0,0 +1,22 @@ +package org.wordpress.android.util; + +import android.text.TextUtils; + +public class GravatarUtils { + /* + * see https://en.gravatar.com/site/implement/images/ + */ + public static String gravatarUrlFromEmail(final String email, int size) { + if (TextUtils.isEmpty(email)) + return ""; + + String url = "http://gravatar.com/avatar/" + + StringUtils.getMd5Hash(email) + + "?d=mm"; + + if (size > 0) + url += "&s=" + Integer.toString(size); + + return url; + } +} diff --git a/WordPressUtils/src/main/java/org/wordpress/android/util/HtmlUtils.java b/WordPressUtils/src/main/java/org/wordpress/android/util/HtmlUtils.java new file mode 100644 index 000000000000..c79fe0ecb079 --- /dev/null +++ b/WordPressUtils/src/main/java/org/wordpress/android/util/HtmlUtils.java @@ -0,0 +1,138 @@ +package org.wordpress.android.util; + +import android.content.Context; +import android.content.res.Resources; +import android.text.Html; +import android.text.SpannableStringBuilder; +import android.text.Spanned; +import android.text.TextUtils; +import android.text.style.ForegroundColorSpan; +import android.text.style.QuoteSpan; + +import org.apache.commons.lang.StringEscapeUtils; + +public class HtmlUtils { + /* + * removes html from the passed string - relies on Html.fromHtml which handles invalid HTML, + * but it's very slow, so avoid using this where performance is important + */ + public static String stripHtml(final String text) { + if (TextUtils.isEmpty(text)) { + return ""; + } + return Html.fromHtml(text).toString().trim(); + } + + /* + * this is much faster than stripHtml() but should only be used when we know the html is valid + * since the regex will be unpredictable with invalid html + */ + public static String fastStripHtml(String str) { + if (TextUtils.isEmpty(str)) { + return str; + } + + // insert a line break before P tags unless the only one is at the start + if (str.lastIndexOf(" 0) { + str = str.replaceAll("", "\n

        "); + } + + // convert BR tags to line breaks + if (str.contains("", "\n"); + } + + // use regex to strip tags, then convert entities in the result + return trimStart(fastUnescapeHtml(str.replaceAll("<(.|\n)*?>", ""))); + } + + /* + * same as apache.commons.lang.StringUtils.stripStart() but also removes non-breaking + * space (160) chars + */ + private static String trimStart(final String str) { + int strLen; + if (str == null || (strLen = str.length()) == 0) { + return ""; + } + int start = 0; + while (start != strLen && (Character.isWhitespace(str.charAt(start)) || str.charAt(start) == 160)) { + start++; + } + return str.substring(start); + } + + /* + * convert html entities to actual Unicode characters - relies on commons apache lang + */ + public static String fastUnescapeHtml(final String text) { + if (text == null || !text.contains("&")) { + return text; + } + return StringEscapeUtils.unescapeHtml(text); + } + + /* + * converts an R.color.xxx resource to an HTML hex color + */ + public static String colorResToHtmlColor(Context context, int resId) { + try { + return String.format("#%06X", 0xFFFFFF & context.getResources().getColor(resId)); + } catch (Resources.NotFoundException e) { + return "#000000"; + } + } + + /* + * remove blocks from the passed string - added to project after noticing + * comments on posts that use the "Sociable" plugin ( http://wordpress.org/plugins/sociable/ ) + * may have a script block which contains followed by a CDATA section followed by , + * all of which will show up if we don't strip it here (example: http://cl.ly/image/0J0N3z3h1i04 ) + * first seen at http://houseofgeekery.com/2013/11/03/13-terrible-x-men-we-wont-see-in-the-movies/ + */ + public static String stripScript(final String text) { + if (text == null) { + return null; + } + + StringBuilder sb = new StringBuilder(text); + int start = sb.indexOf(" -1) { + int end = sb.indexOf("", start); + if (end == -1) { + return sb.toString(); + } + sb.delete(start, end + 9); + start = sb.indexOf(",

          ,
          tags and replacing Emoticons with Emojis + */ + public static SpannableStringBuilder fromHtml(String source, WPImageGetter wpImageGetter) { + SpannableStringBuilder html; + try { + html = (SpannableStringBuilder) Html.fromHtml(source, wpImageGetter, new WPHtmlTagHandler()); + } catch (RuntimeException runtimeException) { + // In case our tag handler fails + html = (SpannableStringBuilder) Html.fromHtml(source, wpImageGetter, null); + } + Emoticons.replaceEmoticonsWithEmoji(html); + QuoteSpan spans[] = html.getSpans(0, html.length(), QuoteSpan.class); + for (QuoteSpan span : spans) { + html.setSpan(new WPQuoteSpan(), html.getSpanStart(span), html.getSpanEnd(span), html.getSpanFlags(span)); + html.setSpan(new ForegroundColorSpan(0xFF666666), html.getSpanStart(span), html.getSpanEnd(span), + html.getSpanFlags(span)); + html.removeSpan(span); + } + return html; + } + + public static Spanned fromHtml(String source) { + return fromHtml(source, null); + } +} diff --git a/WordPressUtils/src/main/java/org/wordpress/android/util/ImageUtils.java b/WordPressUtils/src/main/java/org/wordpress/android/util/ImageUtils.java new file mode 100644 index 000000000000..31dadc911124 --- /dev/null +++ b/WordPressUtils/src/main/java/org/wordpress/android/util/ImageUtils.java @@ -0,0 +1,554 @@ +package org.wordpress.android.util; + +import android.content.ContentResolver; +import android.content.Context; +import android.database.Cursor; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.graphics.Canvas; +import android.graphics.Color; +import android.graphics.Matrix; +import android.graphics.Paint; +import android.graphics.PorterDuff; +import android.graphics.PorterDuffXfermode; +import android.graphics.Rect; +import android.graphics.RectF; +import android.media.ExifInterface; +import android.net.Uri; +import android.os.AsyncTask; +import android.provider.MediaStore; +import android.text.TextUtils; +import android.widget.ImageView; + +import org.apache.http.HttpEntity; +import org.apache.http.HttpResponse; +import org.apache.http.HttpStatus; +import org.apache.http.client.methods.HttpGet; +import org.apache.http.impl.client.DefaultHttpClient; + +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.lang.ref.WeakReference; + +public class ImageUtils { + public static int[] getImageSize(Uri uri, Context context){ + String path = null; + BitmapFactory.Options options = new BitmapFactory.Options(); + options.inJustDecodeBounds = true; + + if (uri.toString().contains("content:")) { + String[] projection = new String[] { MediaStore.Images.Media._ID, MediaStore.Images.Media.DATA }; + Cursor cur = context.getContentResolver().query(uri, projection, null, null, null); + if (cur != null) { + if (cur.moveToFirst()) { + int dataColumn = cur.getColumnIndex(MediaStore.Images.Media.DATA); + path = cur.getString(dataColumn); + } + cur.close(); + } + } + + if (TextUtils.isEmpty(path)) { + //The file isn't ContentResolver, or it can't be access by ContentResolver. Try to access the file directly. + path = uri.toString().replace("content://media", ""); + path = path.replace("file://", ""); + } + + BitmapFactory.decodeFile(path, options); + int imageHeight = options.outHeight; + int imageWidth = options.outWidth; + return new int[]{imageWidth, imageHeight}; + } + + // Read the orientation from ContentResolver. If it fails, read from EXIF. + public static int getImageOrientation(Context ctx, String filePath) { + Uri curStream; + int orientation = 0; + + // Remove file protocol + filePath = filePath.replace("file://", ""); + + if (!filePath.contains("content://")) + curStream = Uri.parse("content://media" + filePath); + else + curStream = Uri.parse(filePath); + + try { + Cursor cur = ctx.getContentResolver().query(curStream, new String[]{MediaStore.Images.Media.ORIENTATION}, null, null, null); + if (cur != null) { + if (cur.moveToFirst()) { + orientation = cur.getInt(cur.getColumnIndex(MediaStore.Images.Media.ORIENTATION)); + } + cur.close(); + } + } catch (Exception errReadingContentResolver) { + AppLog.e(AppLog.T.UTILS, errReadingContentResolver); + } + + if (orientation == 0) { + orientation = getExifOrientation(filePath); + } + + return orientation; + } + + + public static int getExifOrientation(String path) { + ExifInterface exif; + try { + exif = new ExifInterface(path); + } catch (IOException e) { + AppLog.e(AppLog.T.UTILS, e); + return 0; + } + + int exifOrientation = exif.getAttributeInt(ExifInterface.TAG_ORIENTATION, 0); + + switch (exifOrientation) { + case ExifInterface.ORIENTATION_NORMAL: + return 0; + case ExifInterface.ORIENTATION_ROTATE_90: + return 90; + case ExifInterface.ORIENTATION_ROTATE_180: + return 180; + case ExifInterface.ORIENTATION_ROTATE_270: + return 270; + default: + return 0; + } + } + + public static Bitmap downloadBitmap(String url) { + final DefaultHttpClient client = new DefaultHttpClient(); + + final HttpGet getRequest = new HttpGet(url); + + try { + HttpResponse response = client.execute(getRequest); + final int statusCode = response.getStatusLine().getStatusCode(); + if (statusCode != HttpStatus.SC_OK) { + AppLog.w(AppLog.T.UTILS, "ImageDownloader Error " + statusCode + + " while retrieving bitmap from " + url); + return null; + } + + final HttpEntity entity = response.getEntity(); + if (entity != null) { + InputStream inputStream = null; + try { + inputStream = entity.getContent(); + return BitmapFactory.decodeStream(inputStream); + } finally { + if (inputStream != null) { + inputStream.close(); + } + entity.consumeContent(); + } + } + } catch (Exception e) { + // Could provide a more explicit error message for IOException or + // IllegalStateException + getRequest.abort(); + AppLog.w(AppLog.T.UTILS, "ImageDownloader Error while retrieving bitmap from " + url); + } + return null; + } + + /** From http://developer.android.com/training/displaying-bitmaps/load-bitmap.html **/ + public static int calculateInSampleSize(BitmapFactory.Options options, int reqWidth, int reqHeight) { + // Raw height and width of image + final int height = options.outHeight; + final int width = options.outWidth; + int inSampleSize = 1; + + if (height > reqHeight || width > reqWidth) { + // Calculate ratios of height and width to requested height and width + final int heightRatio = Math.round((float) height / (float) reqHeight); + final int widthRatio = Math.round((float) width / (float) reqWidth); + + // Choose the smallest ratio as inSampleSize value, this will guarantee + // a final image with both dimensions larger than or equal to the + // requested height and width. + inSampleSize = heightRatio < widthRatio ? heightRatio : widthRatio; + } + + return inSampleSize; + } + + + public interface BitmapWorkerCallback { + public void onBitmapReady(String filePath, ImageView imageView, Bitmap bitmap); + } + + public static class BitmapWorkerTask extends AsyncTask { + private final WeakReference imageViewReference; + private final BitmapWorkerCallback callback; + private int targetWidth; + private int targetHeight; + private String path; + + public BitmapWorkerTask(ImageView imageView, int width, int height, BitmapWorkerCallback callback) { + // Use a WeakReference to ensure the ImageView can be garbage collected + imageViewReference = new WeakReference(imageView); + this.callback = callback; + targetWidth = width; + targetHeight = height; + } + + // Decode image in background. + @Override + protected Bitmap doInBackground(String... params) { + path = params[0]; + + BitmapFactory.Options bfo = new BitmapFactory.Options(); + bfo.inJustDecodeBounds = true; + BitmapFactory.decodeFile(path, bfo); + + bfo.inSampleSize = calculateInSampleSize(bfo, targetWidth, targetHeight); + bfo.inJustDecodeBounds = false; + + // get proper rotation + int bitmapWidth = 0; + int bitmapHeight = 0; + try { + File f = new File(path); + ExifInterface exif = new ExifInterface(f.getPath()); + int orientation = exif.getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL); + int angle = 0; + if (orientation == ExifInterface.ORIENTATION_NORMAL) { // no need to rotate + return BitmapFactory.decodeFile(path, bfo); + } else if (orientation == ExifInterface.ORIENTATION_ROTATE_90) { + angle = 90; + } else if (orientation == ExifInterface.ORIENTATION_ROTATE_180) { + angle = 180; + } else if (orientation == ExifInterface.ORIENTATION_ROTATE_270) { + angle = 270; + } + + Matrix mat = new Matrix(); + mat.postRotate(angle); + + try { + Bitmap bmp = BitmapFactory.decodeStream(new FileInputStream(f), null, bfo); + if (bmp == null) { + AppLog.e(AppLog.T.UTILS, "can't decode bitmap: " + f.getPath()); + return null; + } + bitmapWidth = bmp.getWidth(); + bitmapHeight = bmp.getHeight(); + return Bitmap.createBitmap(bmp, 0, 0, bmp.getWidth(), bmp.getHeight(), mat, true); + } catch (OutOfMemoryError oom) { + AppLog.e(AppLog.T.UTILS, "OutOfMemoryError Error in setting image: " + oom); + } + } catch (IOException e) { + AppLog.e(AppLog.T.UTILS, "Error in setting image", e); + } + + return null; + } + + // Once complete, see if ImageView is still around and set bitmap. + @Override + protected void onPostExecute(Bitmap bitmap) { + if (imageViewReference == null || bitmap == null) + return; + + final ImageView imageView = imageViewReference.get(); + + if (callback != null) + callback.onBitmapReady(path, imageView, bitmap); + + } + } + + + public static String getTitleForWPImageSpan(Context ctx, String filePath) { + if (filePath == null) + return null; + + Uri curStream; + String title; + + if (!filePath.contains("content://")) + curStream = Uri.parse("content://media" + filePath); + else + curStream = Uri.parse(filePath); + + if (filePath.contains("video")) { + return "Video"; + } else { + String[] projection = new String[] { MediaStore.Images.Thumbnails.DATA }; + + Cursor cur; + try { + cur = ctx.getContentResolver().query(curStream, projection, null, null, null); + } catch (Exception e1) { + AppLog.e(AppLog.T.UTILS, e1); + return null; + } + File jpeg; + if (cur != null) { + String thumbData = ""; + if (cur.moveToFirst()) { + int dataColumn = cur.getColumnIndex(MediaStore.Images.Media.DATA); + thumbData = cur.getString(dataColumn); + } + cur.close(); + if (thumbData == null) { + return null; + } + jpeg = new File(thumbData); + } else { + String path = filePath.toString().replace("file://", ""); + jpeg = new File(path); + } + title = jpeg.getName(); + return title; + } + } + + /** + * Resizes an image to be placed in the Post Content Editor + * + * @return resized bitmap + */ + public static Bitmap getWPImageSpanThumbnailFromFilePath(Context context, String filePath, int targetWidth) { + if (filePath == null || context == null) { + return null; + } + + Uri curUri; + if (!filePath.contains("content://")) { + curUri = Uri.parse("content://media" + filePath); + } else { + curUri = Uri.parse(filePath); + } + + if (filePath.contains("video")) { + // Load the video thumbnail from the MediaStore + int videoId = 0; + try { + videoId = Integer.parseInt(curUri.getLastPathSegment()); + } catch (NumberFormatException e) { + } + ContentResolver crThumb = context.getContentResolver(); + BitmapFactory.Options options = new BitmapFactory.Options(); + options.inSampleSize = 1; + Bitmap videoThumbnail = MediaStore.Video.Thumbnails.getThumbnail(crThumb, videoId, MediaStore.Video.Thumbnails.MINI_KIND, + options); + if (videoThumbnail != null) { + return getScaledBitmapAtLongestSide(videoThumbnail, targetWidth); + } else { + return null; + } + } else { + // Create resized bitmap + int rotation = getImageOrientation(context, filePath); + byte[] bytes = createThumbnailFromUri(context, curUri, targetWidth, null, rotation); + + if (bytes != null && bytes.length > 0) { + try { + Bitmap resizedBitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.length); + if (resizedBitmap != null) { + return getScaledBitmapAtLongestSide(resizedBitmap, targetWidth); + } + } catch (OutOfMemoryError e) { + AppLog.e(AppLog.T.UTILS, "OutOfMemoryError Error in setting image: " + e); + return null; + } + } + } + + return null; + } + + /* + Resize a bitmap to the targetSize on its longest side. + */ + public static Bitmap getScaledBitmapAtLongestSide(Bitmap bitmap, int targetSize) { + if (bitmap.getWidth() <= targetSize && bitmap.getHeight() <= targetSize) { + // Do not resize. + return bitmap; + } + + int targetWidth, targetHeight; + if (bitmap.getHeight() > bitmap.getWidth()) { + // Resize portrait bitmap + targetHeight = targetSize; + float percentage = (float) targetSize / bitmap.getHeight(); + targetWidth = (int)(bitmap.getWidth() * percentage); + } else { + // Resize landscape or square image + targetWidth = targetSize; + float percentage = (float) targetSize / bitmap.getWidth(); + targetHeight = (int)(bitmap.getHeight() * percentage); + } + + return Bitmap.createScaledBitmap(bitmap, targetWidth, targetHeight, true); + } + + /** + * nbradbury - 21-Feb-2014 - similar to createThumbnail but more efficient since it doesn't + * require passing the full-size image as an array of bytes[] + */ + public static byte[] createThumbnailFromUri(Context context, + Uri imageUri, + int maxWidth, + String fileExtension, + int rotation) { + if (context == null || imageUri == null || maxWidth <= 0) + return null; + + String filePath = null; + if (imageUri.toString().contains("content:")) { + String[] projection = new String[] { MediaStore.Images.Media.DATA }; + Cursor cur = context.getContentResolver().query(imageUri, projection, null, null, null); + if (cur != null) { + if (cur.moveToFirst()) { + int dataColumn = cur.getColumnIndex(MediaStore.Images.Media.DATA); + filePath = cur.getString(dataColumn); + } + cur.close(); + } + } + + if (TextUtils.isEmpty(filePath)) { + //access the file directly + filePath = imageUri.toString().replace("content://media", ""); + filePath = filePath.replace("file://", ""); + } + + // get just the image bounds + BitmapFactory.Options optBounds = new BitmapFactory.Options(); + optBounds.inJustDecodeBounds = true; + + try { + BitmapFactory.decodeFile(filePath, optBounds); + } catch (OutOfMemoryError e) { + AppLog.e(AppLog.T.UTILS, "OutOfMemoryError Error in setting image: " + e); + return null; + } + + // determine correct scale value (should be power of 2) + // http://stackoverflow.com/questions/477572/android-strange-out-of-memory-issue/3549021#3549021 + int scale = 1; + if (maxWidth > 0 && optBounds.outWidth > maxWidth) { + double d = Math.pow(2, (int) Math.round(Math.log(maxWidth / (double) optBounds.outWidth) / Math.log(0.5))); + scale = (int) d; + } + + BitmapFactory.Options optActual = new BitmapFactory.Options(); + optActual.inSampleSize = scale; + + // Get the roughly resized bitmap + Bitmap bmpResized; + try { + bmpResized = BitmapFactory.decodeFile(filePath, optActual); + } catch (OutOfMemoryError e) { + AppLog.e(AppLog.T.UTILS, "OutOfMemoryError Error in setting image: " + e); + return null; + } + + if (bmpResized == null) + return null; + + ByteArrayOutputStream stream = new ByteArrayOutputStream(); + + // Now calculate exact scale in order to resize accurately + float percentage = (float) maxWidth / bmpResized.getWidth(); + float proportionateHeight = bmpResized.getHeight() * percentage; + int finalHeight = (int) Math.rint(proportionateHeight); + + float scaleWidth = ((float) maxWidth) / bmpResized.getWidth(); + float scaleHeight = ((float) finalHeight) / bmpResized.getHeight(); + + float scaleBy = Math.min(scaleWidth, scaleHeight); + + // Resize the bitmap to exact size + Matrix matrix = new Matrix(); + matrix.postScale(scaleBy, scaleBy); + + // apply rotation + if (rotation != 0) { + matrix.setRotate(rotation); + } + + Bitmap.CompressFormat fmt; + if (fileExtension != null && fileExtension.equalsIgnoreCase("png")) { + fmt = Bitmap.CompressFormat.PNG; + } else { + fmt = Bitmap.CompressFormat.JPEG; + } + + final Bitmap bmpRotated; + try { + bmpRotated = Bitmap.createBitmap(bmpResized, 0, 0, bmpResized.getWidth(), bmpResized.getHeight(), matrix, + true); + } catch (OutOfMemoryError e) { + AppLog.e(AppLog.T.UTILS, "OutOfMemoryError Error in setting image: " + e); + return null; + } + bmpRotated.compress(fmt, 100, stream); + bmpResized.recycle(); + bmpRotated.recycle(); + + return stream.toByteArray(); + } + + public static Bitmap getCircularBitmap(final Bitmap bitmap) { + if (bitmap==null) + return null; + + final Bitmap output = Bitmap.createBitmap(bitmap.getWidth(), bitmap.getHeight(), Bitmap.Config.ARGB_8888); + final Canvas canvas = new Canvas(output); + final Paint paint = new Paint(); + final Rect rect = new Rect(0, 0, bitmap.getWidth(), bitmap.getHeight()); + final RectF rectF = new RectF(rect); + + paint.setAntiAlias(true); + canvas.drawARGB(0, 0, 0, 0); + paint.setColor(Color.RED); + canvas.drawOval(rectF, paint); + + paint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.SRC_IN)); + canvas.drawBitmap(bitmap, rect, rect, paint); + + // outline + paint.setStyle(Paint.Style.STROKE); + paint.setStrokeWidth(1f); + paint.setColor(Color.DKGRAY); + canvas.drawOval(rectF, paint); + + return output; + } + + public static Bitmap getRoundedEdgeBitmap(final Bitmap bitmap, int radius) { + if (bitmap == null) { + return null; + } + + final Bitmap output = Bitmap.createBitmap(bitmap.getWidth(), bitmap.getHeight(), Bitmap.Config.ARGB_8888); + final Canvas canvas = new Canvas(output); + final Paint paint = new Paint(); + final Rect rect = new Rect(0, 0, bitmap.getWidth(), bitmap.getHeight()); + final RectF rectF = new RectF(rect); + + paint.setAntiAlias(true); + canvas.drawARGB(0, 0, 0, 0); + paint.setColor(Color.RED); + canvas.drawRoundRect(rectF, radius, radius, paint); + + paint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.SRC_IN)); + canvas.drawBitmap(bitmap, rect, rect, paint); + + paint.setStyle(Paint.Style.STROKE); + paint.setStrokeWidth(1f); + paint.setColor(Color.DKGRAY); + canvas.drawRoundRect(rectF, radius, radius, paint); + + return output; + } +} diff --git a/WordPressUtils/src/main/java/org/wordpress/android/util/JSONUtil.java b/WordPressUtils/src/main/java/org/wordpress/android/util/JSONUtil.java new file mode 100644 index 000000000000..199fba703db0 --- /dev/null +++ b/WordPressUtils/src/main/java/org/wordpress/android/util/JSONUtil.java @@ -0,0 +1,236 @@ +package org.wordpress.android.util; + +import android.text.TextUtils; + +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; +import org.wordpress.android.util.AppLog.T; + +import java.util.ArrayList; + +public class JSONUtil { + private static String QUERY_SEPERATOR="."; + private static String QUERY_ARRAY_INDEX_START="["; + private static String QUERY_ARRAY_INDEX_END="]"; + private static String QUERY_ARRAY_FIRST="first"; + private static String QUERY_ARRAY_LAST="last"; + + private static final String JSON_NULL_STR = "null"; + + private static final String TAG="JSONUtil"; + /** + * Given a JSONObject and a key path (e.g property.child) and a default it will + * traverse the object graph and pull out the desired property + */ + public static U queryJSON(JSONObject source, String query, U defaultObject) { + int nextSeperator = query.indexOf(QUERY_SEPERATOR); + int nextIndexStart = query.indexOf(QUERY_ARRAY_INDEX_START); + if (nextSeperator == -1 && nextIndexStart == -1) { + // last item let's get it + try { + if (!source.has(query)) { + return defaultObject; + } + Object result = source.get(query); + if (result.getClass().isAssignableFrom(defaultObject.getClass())) { + return (U) result; + } else { + return defaultObject; + } + } catch (java.lang.ClassCastException e) { + AppLog.e(T.UTILS, "Unable to cast the object to " + defaultObject.getClass().getName(), e); + return defaultObject; + } catch (JSONException e) { + AppLog.e(T.UTILS, "Unable to get the Key from the input object. Key:" + query, e); + return defaultObject; + } + } + int endQuery; + if (nextSeperator == -1 || nextIndexStart == -1) { + endQuery = Math.max(nextSeperator, nextIndexStart); + } else { + endQuery = Math.min(nextSeperator, nextIndexStart); + } + String nextQuery = query.substring(endQuery); + String key = query.substring(0, endQuery); + try { + if (source == null) { + return defaultObject; + } + if (nextQuery.indexOf(QUERY_SEPERATOR) == 0) { + return queryJSON(source.getJSONObject(key), nextQuery.substring(1), defaultObject); + } else if (nextQuery.indexOf(QUERY_ARRAY_INDEX_START) == 0) { + return queryJSON(source.getJSONArray(key), nextQuery, defaultObject); + } else if (!nextQuery.equals("")) { + return defaultObject; + } + Object result = source.get(key); + if (result.getClass().isAssignableFrom(defaultObject.getClass())) { + return (U) result; + } else { + AppLog.w(T.UTILS, String.format("The returned object type %s is not assignable to the type %s. Using default!", + result.getClass(),defaultObject.getClass())); + return defaultObject; + } + } catch (java.lang.ClassCastException e) { + AppLog.e(T.UTILS, "Unable to cast the object to " + defaultObject.getClass().getName(), e); + return defaultObject; + } catch (JSONException e) { + AppLog.e(T.UTILS, "Unable to get the Key from the input object. Key:" + query, e); + return defaultObject; + } + } + + /** + * Given a JSONArray and a query (e.g. [0].property) it will traverse the array and + * pull out the requested property. + * + * Acceptable indexes include negative numbers to reference items from the end of + * the list as well as "last" and "first" as more explicit references to "0" and "-1" + */ + public static U queryJSON(JSONArray source, String query, U defaultObject){ + // query must start with [ have an index and then have ] + int indexStart = query.indexOf(QUERY_ARRAY_INDEX_START); + int indexEnd = query.indexOf(QUERY_ARRAY_INDEX_END); + if (indexStart == -1 || indexEnd == -1 || indexStart > indexEnd) { + return defaultObject; + } + // get "index" from "[index]" + String indexStr = query.substring(indexStart + 1, indexEnd); + int index; + if (indexStr.equals(QUERY_ARRAY_FIRST)) { + index = 0; + } else if (indexStr.equals(QUERY_ARRAY_LAST)) { + index = -1; + } else { + index = Integer.parseInt(indexStr); + } + if (index < 0) { + index = source.length() + index; + } + // copy remaining query + String remainingQuery = query.substring(indexEnd + 1); + try { + if (remainingQuery.indexOf(QUERY_ARRAY_INDEX_START) == 0) { + return queryJSON(source.getJSONArray(index), remainingQuery, defaultObject); + } else if (remainingQuery.indexOf(QUERY_SEPERATOR) == 0) { + return queryJSON(source.getJSONObject(index), remainingQuery.substring(1), defaultObject); + } else if (!remainingQuery.equals("")) { + // TODO throw an exception since the query isn't valid? + AppLog.w(T.UTILS, String.format("Incorrect query for next object %s", remainingQuery)); + return defaultObject; + } + Object result = source.get(index); + if (result.getClass().isAssignableFrom(defaultObject.getClass())) { + return (U) result; + } else { + AppLog.w(T.UTILS, String.format("The returned object type %s is not assignable to the type %s. Using default!", + result.getClass(),defaultObject.getClass())); + return defaultObject; + } + } catch (java.lang.ClassCastException e) { + AppLog.e(T.UTILS, "Unable to cast the object to "+defaultObject.getClass().getName(), e); + return defaultObject; + } catch (JSONException e) { + AppLog.e(T.UTILS, "Unable to get the Key from the input object. Key:" + query, e); + return defaultObject; + } + } + + /** + * Convert a JSONArray (expected to contain strings) in a string list + */ + public static ArrayList fromJSONArrayToStringList(JSONArray jsonArray) { + ArrayList stringList = new ArrayList(); + for (int i = 0; i < jsonArray.length(); i++) { + try { + stringList.add(jsonArray.getString(i)); + } catch (JSONException e) { + AppLog.e(T.UTILS, e); + } + } + return stringList; + } + + /** + * Convert a string list in a JSONArray + */ + public static JSONArray fromStringListToJSONArray(ArrayList stringList) { + JSONArray jsonArray = new JSONArray(); + if (stringList != null) { + for (int i = 0; i < stringList.size(); i++) { + jsonArray.put(stringList.get(i)); + } + } + return jsonArray; + } + + /* + * wrapper for JSONObject.optString() which handles "null" values + */ + public static String getString(JSONObject json, String name) { + String value = json.optString(name); + // return empty string for "null" + if (JSON_NULL_STR.equals(value)) + return ""; + return value; + } + + /* + * use with strings that contain HTML entities + */ + public static String getStringDecoded(JSONObject json, String name) { + String value = getString(json, name); + return HtmlUtils.fastUnescapeHtml(value); + } + + /* + * replacement for JSONObject.optBoolean() - optBoolean() only checks for "true" and "false", + * but our API sometimes uses "0" to denote false + */ + public static boolean getBool(JSONObject json, String name) { + String value = getString(json, name); + if (TextUtils.isEmpty(value)) + return false; + if (value.equals("0")) + return false; + if (value.equalsIgnoreCase("false")) + return false; + return true; + } + + /* + * returns the JSONObject child of the passed parent that matches the passed query + * this is basically an "optJSONObject" that supports nested queries, for example: + * + * getJSONChild("meta/data/site") + * + * would find this: + * + * "meta": { + * "data": { + * "site": { + * "ID": 3584907, + * "name": "WordPress.com News", + * } + * } + * } + */ + public static JSONObject getJSONChild(final JSONObject jsonParent, final String query) { + if (jsonParent == null || TextUtils.isEmpty(query)) + return null; + String[] names = query.split("/"); + JSONObject jsonChild = null; + for (int i = 0; i < names.length; i++) { + if (jsonChild == null) { + jsonChild = jsonParent.optJSONObject(names[i]); + } else { + jsonChild = jsonChild.optJSONObject(names[i]); + } + if (jsonChild == null) + return null; + } + return jsonChild; + } +} \ No newline at end of file diff --git a/WordPressUtils/src/main/java/org/wordpress/android/util/ListScrollPositionManager.java b/WordPressUtils/src/main/java/org/wordpress/android/util/ListScrollPositionManager.java new file mode 100644 index 000000000000..d60e9da6c6b2 --- /dev/null +++ b/WordPressUtils/src/main/java/org/wordpress/android/util/ListScrollPositionManager.java @@ -0,0 +1,36 @@ +package org.wordpress.android.util; + +import android.view.View; +import android.widget.ListView; + +public class ListScrollPositionManager { + private int mSelectedPosition; + private int mListViewScrollStateIndex; + private int mListViewScrollStateOffset; + private ListView mListView; + private boolean mSetSelection; + + public ListScrollPositionManager(ListView listView, boolean setSelection) { + mListView = listView; + mSetSelection = setSelection; + } + + public void saveScrollOffset() { + mListViewScrollStateIndex = mListView.getFirstVisiblePosition(); + View view = mListView.getChildAt(0); + mListViewScrollStateOffset = 0; + if (view != null) { + mListViewScrollStateOffset = view.getTop(); + } + if (mSetSelection) { + mSelectedPosition = mListView.getCheckedItemPosition(); + } + } + + public void restoreScrollOffset() { + mListView.setSelectionFromTop(mListViewScrollStateIndex, mListViewScrollStateOffset); + if (mSetSelection) { + mListView.setItemChecked(mSelectedPosition, true); + } + } +} diff --git a/WordPressUtils/src/main/java/org/wordpress/android/util/LocationHelper.java b/WordPressUtils/src/main/java/org/wordpress/android/util/LocationHelper.java new file mode 100644 index 000000000000..12439fd28c87 --- /dev/null +++ b/WordPressUtils/src/main/java/org/wordpress/android/util/LocationHelper.java @@ -0,0 +1,132 @@ +//This Handy-Dandy class acquired and tweaked from http://stackoverflow.com/a/3145655/309558 +package org.wordpress.android.util; + +import java.util.Timer; +import java.util.TimerTask; + +import android.content.Context; +import android.location.Location; +import android.location.LocationListener; +import android.location.LocationManager; +import android.os.Bundle; + +public class LocationHelper { + Timer timer1; + LocationManager lm; + LocationResult locationResult; + boolean gps_enabled = false; + boolean network_enabled = false; + + public boolean getLocation(Context context, LocationResult result) { + locationResult = result; + if (lm == null) + lm = (LocationManager) context + .getSystemService(Context.LOCATION_SERVICE); + + // exceptions will be thrown if provider is not permitted. + try { + gps_enabled = lm.isProviderEnabled(LocationManager.GPS_PROVIDER); + } catch (Exception ex) { + } + try { + network_enabled = lm.isProviderEnabled(LocationManager.NETWORK_PROVIDER); + } catch (Exception ex) { + } + + // don't start listeners if no provider is enabled + if (!gps_enabled && !network_enabled) + return false; + + if (gps_enabled) + lm.requestLocationUpdates(LocationManager.GPS_PROVIDER, 0, 0, locationListenerGps); + + if (network_enabled) + lm.requestLocationUpdates(LocationManager.NETWORK_PROVIDER, 0, 0, locationListenerNetwork); + + timer1 = new Timer(); + timer1.schedule(new GetLastLocation(), 30000); + return true; + } + + LocationListener locationListenerGps = new LocationListener() { + public void onLocationChanged(Location location) { + timer1.cancel(); + locationResult.gotLocation(location); + lm.removeUpdates(this); + lm.removeUpdates(locationListenerNetwork); + } + + public void onProviderDisabled(String provider) { + } + + public void onProviderEnabled(String provider) { + } + + public void onStatusChanged(String provider, int status, Bundle extras) { + } + }; + + LocationListener locationListenerNetwork = new LocationListener() { + public void onLocationChanged(Location location) { + timer1.cancel(); + locationResult.gotLocation(location); + lm.removeUpdates(this); + lm.removeUpdates(locationListenerGps); + } + + public void onProviderDisabled(String provider) { + } + + public void onProviderEnabled(String provider) { + } + + public void onStatusChanged(String provider, int status, Bundle extras) { + } + }; + + class GetLastLocation extends TimerTask { + @Override + public void run() { + lm.removeUpdates(locationListenerGps); + lm.removeUpdates(locationListenerNetwork); + + Location net_loc = null, gps_loc = null; + if (gps_enabled) + gps_loc = lm.getLastKnownLocation(LocationManager.GPS_PROVIDER); + if (network_enabled) + net_loc = lm + .getLastKnownLocation(LocationManager.NETWORK_PROVIDER); + + // if there are both values use the latest one + if (gps_loc != null && net_loc != null) { + if (gps_loc.getTime() > net_loc.getTime()) + locationResult.gotLocation(gps_loc); + else + locationResult.gotLocation(net_loc); + return; + } + + if (gps_loc != null) { + locationResult.gotLocation(gps_loc); + return; + } + if (net_loc != null) { + locationResult.gotLocation(net_loc); + return; + } + locationResult.gotLocation(null); + } + } + + public static abstract class LocationResult { + public abstract void gotLocation(Location location); + } + + public void cancelTimer() { + if (timer1 != null) { + timer1.cancel(); + lm.removeUpdates(locationListenerGps); + lm.removeUpdates(locationListenerNetwork); + } + } +} diff --git a/WordPressUtils/src/main/java/org/wordpress/android/util/MapUtils.java b/WordPressUtils/src/main/java/org/wordpress/android/util/MapUtils.java new file mode 100644 index 000000000000..981e537d257a --- /dev/null +++ b/WordPressUtils/src/main/java/org/wordpress/android/util/MapUtils.java @@ -0,0 +1,79 @@ +package org.wordpress.android.util; + +import java.util.Date; +import java.util.Map; + +/** + * wrappers for extracting values from a Map object + */ +public class MapUtils { + /* + * returns a String value for the passed key in the passed map + * always returns "" instead of null + */ + public static String getMapStr(final Map map, final String key) { + if (map == null || key == null || !map.containsKey(key) || map.get(key) == null) { + return ""; + } + return map.get(key).toString(); + } + + /* + * returns an int value for the passed key in the passed map + * defaultValue is returned if key doesn't exist or isn't a number + */ + public static int getMapInt(final Map map, final String key) { + return getMapInt(map, key, 0); + } + public static int getMapInt(final Map map, final String key, int defaultValue) { + try { + return Integer.parseInt(getMapStr(map, key)); + } catch (NumberFormatException e) { + return defaultValue; + } + } + + /* + * long version of above + */ + public static long getMapLong(final Map map, final String key) { + return getMapLong(map, key, 0); + } + public static long getMapLong(final Map map, final String key, long defaultValue) { + try { + return Long.parseLong(getMapStr(map, key)); + } catch (NumberFormatException e) { + return defaultValue; + } + } + + /* + * returns a date object from the passed key in the passed map + * returns null if key doesn't exist or isn't a date + */ + public static Date getMapDate(final Map map, final String key) { + if (map==null || key==null || !map.containsKey(key)) + return null; + try { + return (Date) map.get(key); + } catch (ClassCastException e) { + return null; + } + } + + /* + * returns a boolean value from the passed key in the passed map + * returns true unless key doesn't exist, or the value is "0" or "false" + */ + public static boolean getMapBool(final Map map, final String key) { + String value = getMapStr(map, key); + if (value.isEmpty()) + return false; + if (value.startsWith("0")) // handles "0" and "0.0" + return false; + if (value.equalsIgnoreCase("false")) + return false; + // all other values are assume to be true + return true; + } +} diff --git a/WordPressUtils/src/main/java/org/wordpress/android/util/PhotonUtils.java b/WordPressUtils/src/main/java/org/wordpress/android/util/PhotonUtils.java new file mode 100644 index 000000000000..497d756ee377 --- /dev/null +++ b/WordPressUtils/src/main/java/org/wordpress/android/util/PhotonUtils.java @@ -0,0 +1,96 @@ +package org.wordpress.android.util; + +import android.text.TextUtils; + +/** + * routines related to the Photon API + * http://developer.wordpress.com/docs/photon/ + */ +public class PhotonUtils { + private PhotonUtils() { + throw new AssertionError(); + } + + /* + * gravatars often contain the ?s= parameter which determines their size - detect this and + * replace it with a new ?s= parameter which requests the avatar at the exact size needed + */ + public static String fixAvatar(final String imageUrl, int avatarSz) { + if (TextUtils.isEmpty(imageUrl)) + return ""; + + // if this isn't a gravatar image, return as resized photon image url + if (!imageUrl.contains("gravatar.com")) + return getPhotonImageUrl(imageUrl, avatarSz, avatarSz); + + // remove all other params, then add query string for size and "mystery man" default + return UrlUtils.removeQuery(imageUrl) + String.format("?s=%d&d=mm", avatarSz); + } + + /* + * returns true if the passed url is an obvious "mshots" url + */ + public static boolean isMshotsUrl(final String imageUrl) { + return (imageUrl != null && imageUrl.contains("/mshots/")); + } + + /* + * returns a photon url for the passed image with the resize query set to the passed dimensions + */ + public static String getPhotonImageUrl(String imageUrl, int width, int height) { + if (TextUtils.isEmpty(imageUrl)) { + return ""; + } + + // make sure it's valid + int schemePos = imageUrl.indexOf("://"); + if (schemePos == -1) { + return imageUrl; + } + + // remove existing query string since it may contain params that conflict with the passed ones + imageUrl = UrlUtils.removeQuery(imageUrl); + + // don't use with GIFs - photon breaks animated GIFs, and sometimes returns a GIF that + // can't be read by BitmapFactory.decodeByteArray (used by Volley in ImageRequest.java + // to decode the downloaded image) + // ex: http://i0.wp.com/lusianne.files.wordpress.com/2013/08/193.gif?resize=768,320 + if (imageUrl.endsWith(".gif")) { + return imageUrl; + } + + // if this is an "mshots" url, skip photon and return it with a query that sets the width/height + // (these are screenshots of the blog that often appear in freshly pressed posts) + // see http://wp.tutsplus.com/tutorials/how-to-generate-website-screenshots-for-your-wordpress-site/ + // ex: http://s.wordpress.com/mshots/v1/http%3A%2F%2Fnickbradbury.com?w=600 + if (isMshotsUrl(imageUrl)) { + return imageUrl + String.format("?w=%d&h=%d", width, height); + } + + // if both width & height are passed use the "resize" param, use only "w" or "h" if just + // one of them is set, otherwise no query string + final String query; + if (width > 0 && height > 0) { + query = String.format("?resize=%d,%d", width, height); + } else if (width > 0) { + query = String.format("?w=%d", width); + } else if (height > 0) { + query = String.format("?h=%d", height); + } else { + query = ""; + } + + // return passed url+query if it's already a photon url + if (imageUrl.contains(".wp.com")) { + if (imageUrl.contains("i0.wp.com") || imageUrl.contains("i1.wp.com") || imageUrl.contains("i2.wp.com")) + return imageUrl + query; + } + + // must use https for https image urls + if (UrlUtils.isHttps(imageUrl)) { + return "https://i0.wp.com/" + imageUrl.substring(schemePos+3, imageUrl.length()) + query; + } else { + return "http://i0.wp.com/" + imageUrl.substring(schemePos+3, imageUrl.length()) + query; + } + } +} diff --git a/WordPressUtils/src/main/java/org/wordpress/android/util/ProfilingUtils.java b/WordPressUtils/src/main/java/org/wordpress/android/util/ProfilingUtils.java new file mode 100644 index 000000000000..251db2a3b7fb --- /dev/null +++ b/WordPressUtils/src/main/java/org/wordpress/android/util/ProfilingUtils.java @@ -0,0 +1,91 @@ +package org.wordpress.android.util; + +import android.content.Context; +import android.content.pm.PackageInfo; +import android.content.pm.PackageManager; +import android.os.SystemClock; + +import org.wordpress.android.util.AppLog.T; + +import java.util.ArrayList; + +/** + * forked from android.util.TimingLogger to use AppLog instead of Log + new static interface. + */ +public class ProfilingUtils { + private static ProfilingUtils sInstance; + + private String mLabel; + private ArrayList mSplits; + private ArrayList mSplitLabels; + + public static void start(String label) { + getInstance().reset(label); + } + + public static void split(String splitLabel) { + getInstance().addSplit(splitLabel); + } + + public static void dump() { + getInstance().dumpToLog(); + } + + private static ProfilingUtils getInstance() { + if (sInstance == null) { + sInstance = new ProfilingUtils(); + } + return sInstance; + } + + public ProfilingUtils() { + reset("init"); + } + + public void reset(String label) { + mLabel = label; + reset(); + } + + public void reset() { + if (mSplits == null) { + mSplits = new ArrayList(); + mSplitLabels = new ArrayList(); + } else { + mSplits.clear(); + mSplitLabels.clear(); + } + addSplit(null); + } + + public void addSplit(String splitLabel) { + long now = SystemClock.elapsedRealtime(); + mSplits.add(now); + mSplitLabels.add(splitLabel); + } + + public void dumpToLog() { + AppLog.d(T.PROFILING, mLabel + ": begin"); + final long first = mSplits.get(0); + long now = first; + for (int i = 1; i < mSplits.size(); i++) { + now = mSplits.get(i); + final String splitLabel = mSplitLabels.get(i); + final long prev = mSplits.get(i - 1); + AppLog.d(T.PROFILING, mLabel + ": " + (now - prev) + " ms, " + splitLabel); + } + AppLog.d(T.PROFILING, mLabel + ": end, " + (now - first) + " ms"); + } + + // Returns app version name String + public static String getVersionName(Context context) { + PackageManager pm = context.getPackageManager(); + try { + PackageInfo pi = pm.getPackageInfo(context.getPackageName(), 0); + return pi.versionName == null ? "" : pi.versionName; + } catch (PackageManager.NameNotFoundException e) { + return ""; + } + } +} + diff --git a/WordPressUtils/src/main/java/org/wordpress/android/util/README.md b/WordPressUtils/src/main/java/org/wordpress/android/util/README.md new file mode 100644 index 000000000000..62a759585e63 --- /dev/null +++ b/WordPressUtils/src/main/java/org/wordpress/android/util/README.md @@ -0,0 +1 @@ +# org.wordpress.android.util \ No newline at end of file diff --git a/WordPressUtils/src/main/java/org/wordpress/android/util/SqlUtils.java b/WordPressUtils/src/main/java/org/wordpress/android/util/SqlUtils.java new file mode 100644 index 000000000000..8d1b4b4379c9 --- /dev/null +++ b/WordPressUtils/src/main/java/org/wordpress/android/util/SqlUtils.java @@ -0,0 +1,121 @@ +package org.wordpress.android.util; + +import android.database.Cursor; +import android.database.DatabaseUtils; +import android.database.sqlite.SQLiteDatabase; +import android.database.sqlite.SQLiteDoneException; +import android.database.sqlite.SQLiteException; +import android.database.sqlite.SQLiteStatement; + +import java.util.ArrayList; +import java.util.List; + +public class SqlUtils { + private SqlUtils() { + throw new AssertionError(); + } + + /* + * SQLite doesn't have a boolean datatype, so booleans are stored as 0=false, 1=true + */ + public static long boolToSql(boolean value) { + return (value ? 1 : 0); + } + public static boolean sqlToBool(int value) { + return (value != 0); + } + + public static void closeStatement(SQLiteStatement stmt) { + if (stmt != null) { + stmt.close(); + } + } + + public static void closeCursor(Cursor c) { + if (c != null && !c.isClosed()) { + c.close(); + } + } + + /* + * wrapper for DatabaseUtils.longForQuery() which returns 0 if query returns no rows + */ + public static long longForQuery(SQLiteDatabase db, String query, String[] selectionArgs) { + try { + return DatabaseUtils.longForQuery(db, query, selectionArgs); + } catch (SQLiteDoneException e) { + return 0; + } + } + + public static int intForQuery(SQLiteDatabase db, String query, String[] selectionArgs) { + long value = longForQuery(db, query, selectionArgs); + return (int)value; + } + + public static boolean boolForQuery(SQLiteDatabase db, String query, String[] selectionArgs) { + long value = longForQuery(db, query, selectionArgs); + return sqlToBool((int) value); + } + + /* + * wrapper for DatabaseUtils.stringForQuery(), returns "" if query returns no rows + */ + public static String stringForQuery(SQLiteDatabase db, String query, String[] selectionArgs) { + try { + return DatabaseUtils.stringForQuery(db, query, selectionArgs); + } catch (SQLiteDoneException e) { + return ""; + } + } + + /* + * returns the number of rows in the passed table + */ + public static long getRowCount(SQLiteDatabase db, String tableName) { + return DatabaseUtils.queryNumEntries(db, tableName); + } + + /* + * removes all rows from the passed table + */ + public static void deleteAllRowsInTable(SQLiteDatabase db, String tableName) { + db.delete(tableName, null, null); + } + + /* + * drop all tables from the passed SQLiteDatabase - make sure to pass a + * writable database + */ + public static boolean dropAllTables(SQLiteDatabase db) throws SQLiteException { + if (db == null) { + return false; + } + + if (db.isReadOnly()) { + throw new SQLiteException("can't drop tables from a read-only database"); + } + + List tableNames = new ArrayList(); + Cursor cursor = db.rawQuery("SELECT name FROM sqlite_master WHERE type='table'", null); + if (cursor.moveToFirst()) { + do { + String tableName = cursor.getString(0); + if (!tableName.equals("android_metadata") && !tableName.equals("sqlite_sequence")) { + tableNames.add(tableName); + } + } while (cursor.moveToNext()); + } + + db.beginTransaction(); + try { + for (String tableName: tableNames) { + db.execSQL("DROP TABLE IF EXISTS " + tableName); + } + db.setTransactionSuccessful(); + return true; + } finally { + db.endTransaction(); + } + } +} diff --git a/WordPressUtils/src/main/java/org/wordpress/android/util/StringUtils.java b/WordPressUtils/src/main/java/org/wordpress/android/util/StringUtils.java new file mode 100644 index 000000000000..eca31ffd169d --- /dev/null +++ b/WordPressUtils/src/main/java/org/wordpress/android/util/StringUtils.java @@ -0,0 +1,278 @@ +package org.wordpress.android.util; + +import android.text.Html; +import android.text.TextUtils; + +import org.wordpress.android.util.AppLog.T; + +import java.math.BigInteger; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +public class StringUtils { + public static String[] mergeStringArrays(String array1[], String array2[]) { + if (array1 == null || array1.length == 0) { + return array2; + } + if (array2 == null || array2.length == 0) { + return array1; + } + List array1List = Arrays.asList(array1); + List array2List = Arrays.asList(array2); + List result = new ArrayList(array1List); + List tmp = new ArrayList(array1List); + tmp.retainAll(array2List); + result.addAll(array2List); + return ((String[]) result.toArray(new String[result.size()])); + } + + public static String convertHTMLTagsForUpload(String source) { + // bold + source = source.replace("", ""); + source = source.replace("", ""); + + // italics + source = source.replace("", ""); + source = source.replace("", ""); + + return source; + } + + public static String convertHTMLTagsForDisplay(String source) { + // bold + source = source.replace("", ""); + source = source.replace("", ""); + + // italics + source = source.replace("", ""); + source = source.replace("", ""); + + return source; + } + + public static String addPTags(String source) { + String[] asploded = source.split("\n\n"); + + if (asploded.length > 0) { + StringBuilder wrappedHTML = new StringBuilder(); + for (int i = 0; i < asploded.length; i++) { + String trimmed = asploded[i].trim(); + if (trimmed.length() > 0) { + trimmed = trimmed.replace("
          ", "
          ").replace("
          ", "
          ").replace("
          \n", "
          ") + .replace("\n", "
          "); + wrappedHTML.append("

          "); + wrappedHTML.append(trimmed); + wrappedHTML.append("

          "); + } + } + return wrappedHTML.toString(); + } else { + return source; + } + } + + public static BigInteger getMd5IntHash(String input) { + try { + MessageDigest md = MessageDigest.getInstance("MD5"); + byte[] messageDigest = md.digest(input.getBytes()); + BigInteger number = new BigInteger(1, messageDigest); + return number; + } catch (NoSuchAlgorithmException e) { + AppLog.e(T.UTILS, e); + return null; + } + } + + public static String getMd5Hash(String input) { + BigInteger number = getMd5IntHash(input); + String md5 = number.toString(16); + while (md5.length() < 32) { + md5 = "0" + md5; + } + return md5; + } + + public static String unescapeHTML(String html) { + if (html != null) { + return Html.fromHtml(html).toString(); + } else { + return ""; + } + } + + /* + * nbradbury - adapted from Html.escapeHtml(), which was added in API Level 16 + * TODO: not thoroughly tested yet, so marked as private - not sure I like the way + * this replaces two spaces with " " + */ + private static String escapeHtml(final String text) { + if (text == null) { + return ""; + } + + StringBuilder out = new StringBuilder(); + int length = text.length(); + + for (int i = 0; i < length; i++) { + char c = text.charAt(i); + + if (c == '<') { + out.append("<"); + } else if (c == '>') { + out.append(">"); + } else if (c == '&') { + out.append("&"); + } else if (c > 0x7E || c < ' ') { + out.append("&#").append((int) c).append(";"); + } else if (c == ' ') { + while (i + 1 < length && text.charAt(i + 1) == ' ') { + out.append(" "); + i++; + } + + out.append(' '); + } else { + out.append(c); + } + } + + return out.toString(); + } + + /* + * returns empty string if passed string is null, otherwise returns passed string + */ + public static String notNullStr(String s) { + if (s == null) { + return ""; + } + return s; + } + + /** + * returns true if two strings are equal or two strings are null + */ + public static boolean equals(String s1, String s2) { + if (s1 == null) { + return s2 == null; + } + return s1.equals(s2); + } + + /* + * capitalizes the first letter in the passed string - based on Apache commons/lang3/StringUtils + * http://svn.apache.org/viewvc/commons/proper/lang/trunk/src/main/java/org/apache/commons/lang3/StringUtils.java?revision=1497829&view=markup + */ + public static String capitalize(final String str) { + int strLen; + if (str == null || (strLen = str.length()) == 0) { + return str; + } + + char firstChar = str.charAt(0); + if (Character.isTitleCase(firstChar)) { + return str; + } + + return new StringBuilder(strLen).append(Character.toTitleCase(firstChar)).append(str.substring(1)).toString(); + } + + /* + * Wrap an image URL in a photon URL + * Check out http://developer.wordpress.com/docs/photon/ + */ + public static String getPhotonUrl(String imageUrl, int size) { + imageUrl = imageUrl.replace("http://", "").replace("https://", ""); + return "http://i0.wp.com/" + imageUrl + "?w=" + size; + } + + public static String getHost(String url) { + if (TextUtils.isEmpty(url)) { + return ""; + } + + int doubleslash = url.indexOf("//"); + if (doubleslash == -1) { + doubleslash = 0; + } else { + doubleslash += 2; + } + + int end = url.indexOf('/', doubleslash); + end = (end >= 0) ? end : url.length(); + + return url.substring(doubleslash, end); + } + + public static String replaceUnicodeSurrogateBlocksWithHTMLEntities(final String inputString) { + final int length = inputString.length(); + StringBuilder out = new StringBuilder(); // Used to hold the output. + for (int offset = 0; offset < length; ) { + final int codepoint = inputString.codePointAt(offset); + final char current = inputString.charAt(offset); + if (Character.isHighSurrogate(current) || Character.isLowSurrogate(current)) { + if (Emoticons.wpSmiliesCodePointToText.get(codepoint) != null) { + out.append(Emoticons.wpSmiliesCodePointToText.get(codepoint)); + } else { + final String htmlEscapedChar = "&#x" + Integer.toHexString(codepoint) + ";"; + out.append(htmlEscapedChar); + } + } else { + out.append(current); + } + offset += Character.charCount(codepoint); + } + return out.toString(); + } + + /** + * This method ensures that the output String has only + * valid XML unicode characters as specified by the + * XML 1.0 standard. For reference, please see + * the + * standard. This method will return an empty + * String if the input is null or empty. + * + * @param in The String whose non-valid characters we want to remove. + * @return The in String, stripped of non-valid characters. + */ + public static final String stripNonValidXMLCharacters(String in) { + StringBuilder out = new StringBuilder(); // Used to hold the output. + char current; // Used to reference the current character. + + if (in == null || ("".equals(in))) { + return ""; // vacancy test. + } + for (int i = 0; i < in.length(); i++) { + current = in.charAt(i); // NOTE: No IndexOutOfBoundsException caught here; it should not happen. + if ((current == 0x9) || + (current == 0xA) || + (current == 0xD) || + ((current >= 0x20) && (current <= 0xD7FF)) || + ((current >= 0xE000) && (current <= 0xFFFD)) || + ((current >= 0x10000) && (current <= 0x10FFFF))) { + out.append(current); + } + } + return out.toString(); + } + + /* + * simple wrapper for Integer.valueOf(string) so caller doesn't need to catch NumberFormatException + */ + public static int stringToInt(String s) { + return stringToInt(s, 0); + } + public static int stringToInt(String s, int defaultValue) { + if (s == null) + return defaultValue; + try { + return Integer.valueOf(s); + } catch (NumberFormatException e) { + return defaultValue; + } + } +} \ No newline at end of file diff --git a/WordPressUtils/src/main/java/org/wordpress/android/util/SystemServiceFactory.java b/WordPressUtils/src/main/java/org/wordpress/android/util/SystemServiceFactory.java new file mode 100644 index 000000000000..4ba0c96ed589 --- /dev/null +++ b/WordPressUtils/src/main/java/org/wordpress/android/util/SystemServiceFactory.java @@ -0,0 +1,17 @@ +package org.wordpress.android.util; + +import android.content.Context; + +import org.wordpress.android.util.AppLog.T; + +public class SystemServiceFactory { + public static SystemServiceFactoryAbstract sFactory; + + public static Object get(Context context, String name) { + if (sFactory == null) { + sFactory = new SystemServiceFactoryDefault(); + } + AppLog.v(T.UTILS, "instantiate SystemService using sFactory: " + sFactory.getClass()); + return sFactory.get(context, name); + } +} diff --git a/WordPressUtils/src/main/java/org/wordpress/android/util/SystemServiceFactoryAbstract.java b/WordPressUtils/src/main/java/org/wordpress/android/util/SystemServiceFactoryAbstract.java new file mode 100644 index 000000000000..a9d522db4c1c --- /dev/null +++ b/WordPressUtils/src/main/java/org/wordpress/android/util/SystemServiceFactoryAbstract.java @@ -0,0 +1,7 @@ +package org.wordpress.android.util; + +import android.content.Context; + +public interface SystemServiceFactoryAbstract { + public Object get(Context context, String name); +} diff --git a/WordPressUtils/src/main/java/org/wordpress/android/util/SystemServiceFactoryDefault.java b/WordPressUtils/src/main/java/org/wordpress/android/util/SystemServiceFactoryDefault.java new file mode 100644 index 000000000000..eb488dde9bf4 --- /dev/null +++ b/WordPressUtils/src/main/java/org/wordpress/android/util/SystemServiceFactoryDefault.java @@ -0,0 +1,9 @@ +package org.wordpress.android.util; + +import android.content.Context; + +public class SystemServiceFactoryDefault implements SystemServiceFactoryAbstract { + public Object get(Context context, String name) { + return context.getSystemService(name); + } +} diff --git a/WordPressUtils/src/main/java/org/wordpress/android/util/ToastUtils.java b/WordPressUtils/src/main/java/org/wordpress/android/util/ToastUtils.java new file mode 100644 index 000000000000..9b99c6ea53e1 --- /dev/null +++ b/WordPressUtils/src/main/java/org/wordpress/android/util/ToastUtils.java @@ -0,0 +1,37 @@ +package org.wordpress.android.util; + +import android.content.Context; +import android.view.Gravity; +import android.widget.Toast; + +/** + * Provides a simplified way to show toast messages without having to create the toast, set the + * desired gravity, etc. + */ +public class ToastUtils { + public enum Duration {SHORT, LONG} + + private ToastUtils() { + throw new AssertionError(); + } + + public static Toast showToast(Context context, int stringResId) { + return showToast(context, stringResId, Duration.SHORT); + } + + public static Toast showToast(Context context, int stringResId, Duration duration) { + return showToast(context, context.getString(stringResId), duration); + } + + public static Toast showToast(Context context, String text) { + return showToast(context, text, Duration.SHORT); + } + + public static Toast showToast(Context context, String text, Duration duration) { + Toast toast = Toast.makeText(context, text, + (duration == Duration.SHORT ? Toast.LENGTH_SHORT : Toast.LENGTH_LONG)); + toast.setGravity(Gravity.CENTER, 0, 0); + toast.show(); + return toast; + } +} diff --git a/WordPressUtils/src/main/java/org/wordpress/android/util/UrlUtils.java b/WordPressUtils/src/main/java/org/wordpress/android/util/UrlUtils.java new file mode 100644 index 000000000000..4438b8950158 --- /dev/null +++ b/WordPressUtils/src/main/java/org/wordpress/android/util/UrlUtils.java @@ -0,0 +1,165 @@ +package org.wordpress.android.util; + +import android.net.Uri; +import android.webkit.MimeTypeMap; +import android.webkit.URLUtil; + +import java.io.UnsupportedEncodingException; +import java.net.IDN; +import java.net.URI; +import java.net.URLDecoder; +import java.net.URLEncoder; +import java.nio.charset.Charset; + +public class UrlUtils { + public static String urlEncode(final String text) { + try { + return URLEncoder.encode(text, "UTF-8"); + } catch (UnsupportedEncodingException e) { + return text; + } + } + + public static String urlDecode(final String text) { + try { + return URLDecoder.decode(text, "UTF-8"); + } catch (UnsupportedEncodingException e) { + return text; + } + } + + public static String getDomainFromUrl(final String urlString) { + if (urlString == null) { + return ""; + } + Uri uri = Uri.parse(urlString); + return uri.getHost(); + } + + /** + * Convert IDN names to punycode if necessary + */ + public static String convertUrlToPunycodeIfNeeded(String url) { + if (!Charset.forName("US-ASCII").newEncoder().canEncode(url)) { + if (url.toLowerCase().startsWith("http://")) { + url = "http://" + IDN.toASCII(url.substring(7)); + } else if (url.toLowerCase().startsWith("https://")) { + url = "https://" + IDN.toASCII(url.substring(8)); + } else { + url = IDN.toASCII(url); + } + } + return url; + } + + public static String addUrlSchemeIfNeeded(String url, boolean isHTTPS) { + if (url == null) { + return null; + } + + if (!URLUtil.isValidUrl(url)) { + if (!(url.toLowerCase().startsWith("http://")) && !(url.toLowerCase().startsWith("https://"))) { + url = (isHTTPS ? "https" : "http") + "://" + url; + } + } + + return url; + } + + /** + * normalizes a URL, primarily for comparison purposes, for example so that + * normalizeUrl("http://google.com/") = normalizeUrl("http://google.com") + */ + public static String normalizeUrl(final String urlString) { + if (urlString == null) { + return null; + } + + // this routine is called from some performance-critical code and creating a URI from a string + // is slow, so skip it when possible - if we know it's not a relative path (and 99.9% of the + // time it won't be for our purposes) then we can normalize it without java.net.URI.normalize() + if (urlString.startsWith("http") && !urlString.contains("build/intermediates/exploded-aar/org.wordpress/graphview/3.1.1")) { + // return without a trailing slash + if (urlString.endsWith("/")) { + return urlString.substring(0, urlString.length() - 1); + } + return urlString; + } + + // url is relative, so fall back to using slower java.net.URI normalization + try { + URI uri = URI.create(urlString); + return uri.normalize().toString(); + } catch (IllegalArgumentException e) { + return urlString; + } + } + + /** + * returns the passed url without the query parameters + */ + public static String removeQuery(final String urlString) { + if (urlString == null) { + return null; + } + int pos = urlString.indexOf("?"); + if (pos == -1) { + return urlString; + } + return urlString.substring(0, pos); + } + + /** + * returns true if passed url is https: + */ + public static boolean isHttps(final String urlString) { + return (urlString != null && urlString.startsWith("https:")); + } + + /** + * returns https: version of passed http: url + */ + public static String makeHttps(final String urlString) { + if (urlString == null || !urlString.startsWith("http:")) { + return urlString; + } + return "https:" + urlString.substring(5, urlString.length()); + } + + /** + * see http://stackoverflow.com/a/8591230/1673548 + */ + public static String getUrlMimeType(final String urlString) { + if (urlString == null) { + return null; + } + + String extension = MimeTypeMap.getFileExtensionFromUrl(urlString); + if (extension == null) { + return null; + } + + MimeTypeMap mime = MimeTypeMap.getSingleton(); + String mimeType = mime.getMimeTypeFromExtension(extension); + if (mimeType == null) { + return null; + } + + return mimeType; + } + + /** + * returns false if the url is not valid or if the url host is null, else true + */ + public static boolean isValidUrlAndHostNotNull(String url) { + try { + URI uri = URI.create(url); + if (uri.getHost() == null) { + return false; + } + } catch (IllegalArgumentException e) { + return false; + } + return true; + } +} diff --git a/WordPressUtils/src/main/java/org/wordpress/android/util/UserEmail.java b/WordPressUtils/src/main/java/org/wordpress/android/util/UserEmail.java new file mode 100644 index 000000000000..dae02b4f01b3 --- /dev/null +++ b/WordPressUtils/src/main/java/org/wordpress/android/util/UserEmail.java @@ -0,0 +1,35 @@ +package org.wordpress.android.util; + +import android.accounts.Account; +import android.accounts.AccountManager; +import android.content.Context; +import android.util.Patterns; + +import java.util.regex.Pattern; + +public class UserEmail { + /** + * Get primary account and return its name if it matches the email address pattern. + * + * @return primary account email address if it can be found or empty string else. + */ + public static String getPrimaryEmail(Context context) { + try { + AccountManager accountManager = AccountManager.get(context); + if (accountManager == null) + return ""; + Account[] accounts = accountManager.getAccounts(); + Pattern emailPattern = Patterns.EMAIL_ADDRESS; + for (Account account : accounts) { + // make sure account.name is an email address before adding to the list + if (emailPattern.matcher(account.name).matches()) { + return account.name; + } + } + return ""; + } catch (SecurityException e) { + // exception will occur if app doesn't have GET_ACCOUNTS permission + return ""; + } + } +} diff --git a/WordPressUtils/src/main/java/org/wordpress/android/util/Version.java b/WordPressUtils/src/main/java/org/wordpress/android/util/Version.java new file mode 100644 index 000000000000..6e695db454da --- /dev/null +++ b/WordPressUtils/src/main/java/org/wordpress/android/util/Version.java @@ -0,0 +1,47 @@ +package org.wordpress.android.util; + +//See: http://stackoverflow.com/a/11024200 +public class Version implements Comparable { + private String version; + + public final String get() { + return this.version; + } + + public Version(String version) { + if(version == null) + throw new IllegalArgumentException("Version can not be null"); + if(!version.matches("[0-9]+(\\.[0-9]+)*")) + throw new IllegalArgumentException("Invalid version format"); + this.version = version; + } + + @Override public int compareTo(Version that) { + if(that == null) + return 1; + String[] thisParts = this.get().split("\\."); + String[] thatParts = that.get().split("\\."); + int length = Math.max(thisParts.length, thatParts.length); + for(int i = 0; i < length; i++) { + int thisPart = i < thisParts.length ? + Integer.parseInt(thisParts[i]) : 0; + int thatPart = i < thatParts.length ? + Integer.parseInt(thatParts[i]) : 0; + if(thisPart < thatPart) + return -1; + if(thisPart > thatPart) + return 1; + } + return 0; + } + + @Override public boolean equals(Object that) { + if(this == that) + return true; + if(that == null) + return false; + if(this.getClass() != that.getClass()) + return false; + return this.compareTo((Version) that) == 0; + } +} \ No newline at end of file diff --git a/WordPressUtils/src/main/java/org/wordpress/android/util/WPHtmlTagHandler.java b/WordPressUtils/src/main/java/org/wordpress/android/util/WPHtmlTagHandler.java new file mode 100644 index 000000000000..fa96a998a23c --- /dev/null +++ b/WordPressUtils/src/main/java/org/wordpress/android/util/WPHtmlTagHandler.java @@ -0,0 +1,59 @@ +package org.wordpress.android.util; + +import android.text.Editable; +import android.text.Html; +import android.text.style.BulletSpan; +import android.text.style.LeadingMarginSpan; + +import org.xml.sax.XMLReader; + +import java.util.Vector; + +/** + * Handle tags that the Html class doesn't understand + * Tweaked from source at http://stackoverflow.com/questions/4044509/android-how-to-use-the-html-taghandler + */ +public class WPHtmlTagHandler implements Html.TagHandler { + private int mListItemCount = 0; + private Vector mListParents = new Vector(); + + @Override + public void handleTag(final boolean opening, final String tag, Editable output, + final XMLReader xmlReader) { + if (tag.equals("ul") || tag.equals("ol") || tag.equals("dd")) { + if (opening) { + mListParents.add(tag); + } else { + mListParents.remove(tag); + } + mListItemCount = 0; + } else if (tag.equals("li") && !opening) { + handleListTag(output); + } + } + + private void handleListTag(Editable output) { + if (mListParents.lastElement().equals("ul")) { + output.append("\n"); + String[] split = output.toString().split("\n"); + int start = 0; + if (split.length != 1) { + int lastIndex = split.length - 1; + start = output.length() - split[lastIndex].length() - 1; + } + output.setSpan(new BulletSpan(15 * mListParents.size()), start, output.length(), 0); + } else if (mListParents.lastElement().equals("ol")) { + mListItemCount++; + output.append("\n"); + String[] split = output.toString().split("\n"); + int start = 0; + if (split.length != 1) { + int lastIndex = split.length - 1; + start = output.length() - split[lastIndex].length() - 1; + } + output.insert(start, mListItemCount + ". "); + output.setSpan(new LeadingMarginSpan.Standard(15 * mListParents.size()), start, + output.length(), 0); + } + } +} diff --git a/WordPressUtils/src/main/java/org/wordpress/android/util/WPImageGetter.java b/WordPressUtils/src/main/java/org/wordpress/android/util/WPImageGetter.java new file mode 100644 index 000000000000..60b0d605b4d4 --- /dev/null +++ b/WordPressUtils/src/main/java/org/wordpress/android/util/WPImageGetter.java @@ -0,0 +1,198 @@ +package org.wordpress.android.util; + +import android.graphics.Canvas; +import android.graphics.drawable.BitmapDrawable; +import android.graphics.drawable.Drawable; +import android.text.Html; +import android.text.TextUtils; +import android.widget.TextView; + +import com.android.volley.VolleyError; +import com.android.volley.toolbox.ImageLoader; + +import org.wordpress.android.util.AppLog.T; + +import java.lang.ref.WeakReference; + +/** + * ImageGetter for Html.fromHtml() + * adapted from existing ImageGetter code in NoteCommentFragment + */ +public class WPImageGetter implements Html.ImageGetter { + private WeakReference mWeakView; + private int mMaxSize; + private ImageLoader mImageLoader; + private Drawable mLoadingDrawable; + private Drawable mFailedDrawable; + + public WPImageGetter(TextView view) { + this(view, 0); + } + + public WPImageGetter(TextView view, int maxSize) { + mWeakView = new WeakReference(view); + mMaxSize = maxSize; + } + + public WPImageGetter(TextView view, int maxSize, ImageLoader imageLoader, Drawable loadingDrawable, + Drawable failedDrawable) { + mWeakView = new WeakReference(view); + mMaxSize = maxSize; + mImageLoader = imageLoader; + mLoadingDrawable = loadingDrawable; + mFailedDrawable = failedDrawable; + } + + public void setImageLoader(ImageLoader imageLoader) { + mImageLoader = imageLoader; + } + + public void setLoadingDrawable(Drawable loadingDrawable) { + mLoadingDrawable = loadingDrawable; + } + + public void setFailedDrawable(Drawable failedDrawable) { + mFailedDrawable = failedDrawable; + } + + private TextView getView() { + return mWeakView.get(); + } + + @Override + public Drawable getDrawable(String source) { + if (mImageLoader == null || mLoadingDrawable == null || mFailedDrawable == null) { + throw new RuntimeException("Developer, you need to call setImageLoader, setLoadingDrawable and setFailedDrawable"); + } + + if (TextUtils.isEmpty(source)) { + return null; + } + + // images in reader comments may skip "http:" (no idea why) so make sure to add protocol here + if (source.startsWith("//")) { + source = "http:" + source; + } + + // use Photon if a max size is requested (otherwise the full-sized image will be downloaded + // and then resized) + if (mMaxSize > 0) { + source = PhotonUtils.getPhotonImageUrl(source, mMaxSize, 0); + } + + TextView view = getView(); + // Drawable loading = view.getContext().getResources().getDrawable(R.drawable.remote_image); FIXME: here + // Drawable failed = view.getContext().getResources().getDrawable(R.drawable.remote_failed); + final RemoteDrawable remote = new RemoteDrawable(mLoadingDrawable, mFailedDrawable); + + mImageLoader.get(source, new ImageLoader.ImageListener() { + @Override + public void onErrorResponse(VolleyError error) { + remote.displayFailed(); + TextView view = getView(); + if (view != null) { + view.invalidate(); + } + } + + @Override + public void onResponse(ImageLoader.ImageContainer response, boolean isImmediate) { + if (response.getBitmap() != null) { + // make sure view is still valid + TextView view = getView(); + if (view == null) { + AppLog.w(T.UTILS, "WPImageGetter view is invalid"); + return; + } + + Drawable drawable = new BitmapDrawable(view.getContext().getResources(), response.getBitmap()); + final int oldHeight = remote.getBounds().height(); + int maxWidth = view.getWidth() - view.getPaddingLeft() - view.getPaddingRight(); + if (mMaxSize > 0 && (maxWidth > mMaxSize || maxWidth == 0)) { + maxWidth = mMaxSize; + } + remote.setRemoteDrawable(drawable, maxWidth); + + // image is from cache? don't need to modify view height + if (isImmediate) { + return; + } + + int newHeight = remote.getBounds().height(); + view.invalidate(); + // For ICS + view.setHeight(view.getHeight() + newHeight - oldHeight); + // Pre ICS + view.setEllipsize(null); + } + } + }); + return remote; + } + + private static class RemoteDrawable extends BitmapDrawable { + protected Drawable mRemoteDrawable; + protected Drawable mLoadingDrawable; + protected Drawable mFailedDrawable; + private boolean mDidFail = false; + + public RemoteDrawable(Drawable loadingDrawable, Drawable failedDrawable) { + mLoadingDrawable = loadingDrawable; + mFailedDrawable = failedDrawable; + setBounds(0, 0, mLoadingDrawable.getIntrinsicWidth(), mLoadingDrawable.getIntrinsicHeight()); + } + + public void displayFailed() { + mDidFail = true; + } + + public void setBounds(int x, int y, int width, int height) { + super.setBounds(x, y, width, height); + if (mRemoteDrawable != null) { + mRemoteDrawable.setBounds(x, y, width, height); + return; + } + if (mLoadingDrawable != null) { + mLoadingDrawable.setBounds(x, y, width, height); + mFailedDrawable.setBounds(x, y, width, height); + } + } + + public void setRemoteDrawable(Drawable remote) { + mRemoteDrawable = remote; + setBounds(0, 0, mRemoteDrawable.getIntrinsicWidth(), mRemoteDrawable.getIntrinsicHeight()); + } + + public void setRemoteDrawable(Drawable remote, int maxWidth) { + // null sentinel for now + if (remote == null) { + // throw error + return; + } + mRemoteDrawable = remote; + // determine if we need to scale the image to fit in view + int imgWidth = remote.getIntrinsicWidth(); + int imgHeight = remote.getIntrinsicHeight(); + float xScale = (float) imgWidth / (float) maxWidth; + if (xScale > 1.0f) { + setBounds(0, 0, Math.round(imgWidth / xScale), Math.round(imgHeight / xScale)); + } else { + setBounds(0, 0, imgWidth, imgHeight); + } + } + + public boolean didFail() { + return mDidFail; + } + + public void draw(Canvas canvas) { + if (mRemoteDrawable != null) { + mRemoteDrawable.draw(canvas); + } else if (didFail()) { + mFailedDrawable.draw(canvas); + } else { + mLoadingDrawable.draw(canvas); + } + } + } +} diff --git a/WordPressUtils/src/main/java/org/wordpress/android/util/WPQuoteSpan.java b/WordPressUtils/src/main/java/org/wordpress/android/util/WPQuoteSpan.java new file mode 100644 index 000000000000..37d5dfe6dee2 --- /dev/null +++ b/WordPressUtils/src/main/java/org/wordpress/android/util/WPQuoteSpan.java @@ -0,0 +1,44 @@ +package org.wordpress.android.util; + +import android.graphics.Canvas; +import android.graphics.Paint; +import android.text.Layout; +import android.text.style.QuoteSpan; + +/** + * Customzed QuoteSpan for use in SpannableString's + */ +public class WPQuoteSpan extends QuoteSpan { + public static final int STRIPE_COLOR = 0xFF21759B; + private static final int STRIPE_WIDTH = 5; + private static final int GAP_WIDTH = 20; + + public WPQuoteSpan(){ + super(STRIPE_COLOR); + } + + @Override + public int getLeadingMargin(boolean first) { + int margin = GAP_WIDTH * 2 + STRIPE_WIDTH; + return margin; + } + + /** + * Draw a nice thick gray bar if Ice Cream Sandwhich or newer. There's a + * bug on older devices that does not respect the increased margin. + */ + @Override + public void drawLeadingMargin(Canvas c, Paint p, int x, int dir, int top, int baseline, int bottom, + CharSequence text, int start, int end, boolean first, Layout layout) { + Paint.Style style = p.getStyle(); + int color = p.getColor(); + + p.setStyle(Paint.Style.FILL); + p.setColor(STRIPE_COLOR); + + c.drawRect(GAP_WIDTH + x, top, x + dir * STRIPE_WIDTH, bottom, p); + + p.setStyle(style); + p.setColor(color); + } +} diff --git a/WordPressUtils/src/main/java/org/wordpress/android/util/WPWebChromeClient.java b/WordPressUtils/src/main/java/org/wordpress/android/util/WPWebChromeClient.java new file mode 100644 index 000000000000..6a40c6f3807b --- /dev/null +++ b/WordPressUtils/src/main/java/org/wordpress/android/util/WPWebChromeClient.java @@ -0,0 +1,29 @@ +package org.wordpress.android.util; + +import android.app.Activity; +import android.view.View; +import android.webkit.WebChromeClient; +import android.webkit.WebView; +import android.widget.ProgressBar; + +public class WPWebChromeClient extends WebChromeClient { + private ProgressBar mProgressBar; + private Activity mActivity; + + public WPWebChromeClient(Activity activity, ProgressBar progressBar) { + mProgressBar = progressBar; + mActivity = activity; + } + + public void onProgressChanged(WebView webView, int progress) { + if (!mActivity.isFinishing()) { + mActivity.setTitle(webView.getTitle()); + } + if (progress == 100) { + mProgressBar.setVisibility(View.GONE); + } else { + mProgressBar.setVisibility(View.VISIBLE); + mProgressBar.setProgress(progress); + } + } +} \ No newline at end of file diff --git a/WordPressUtils/src/main/java/org/wordpress/android/util/ptr/PullToRefreshHeaderTransformer.java b/WordPressUtils/src/main/java/org/wordpress/android/util/ptr/PullToRefreshHeaderTransformer.java new file mode 100644 index 000000000000..3fec8d91fb8b --- /dev/null +++ b/WordPressUtils/src/main/java/org/wordpress/android/util/ptr/PullToRefreshHeaderTransformer.java @@ -0,0 +1,99 @@ +package org.wordpress.android.util.ptr; + +import android.animation.AnimatorSet; +import android.animation.ObjectAnimator; +import android.app.Activity; +import android.view.View; +import android.view.ViewGroup; +import android.view.animation.Animation; + +import org.wordpress.android.util.R; + +import uk.co.senab.actionbarpulltorefresh.library.DefaultHeaderTransformer; +import uk.co.senab.actionbarpulltorefresh.library.sdk.Compat; + +public class PullToRefreshHeaderTransformer extends DefaultHeaderTransformer { + private View mHeaderView; + private ViewGroup mContentLayout; + private long mAnimationDuration; + private boolean mShowProgressBarOnly; + private Animation mHeaderOutAnimation; + private OnTopScrollChangedListener mOnTopScrollChangedListener; + + public interface OnTopScrollChangedListener { + public void onTopScrollChanged(boolean scrolledOnTop); + } + + public void setShowProgressBarOnly(boolean progressBarOnly) { + mShowProgressBarOnly = progressBarOnly; + } + + @Override + public void onViewCreated(Activity activity, View headerView) { + super.onViewCreated(activity, headerView); + mHeaderView = headerView; + mContentLayout = (ViewGroup) headerView.findViewById(R.id.ptr_content); + mAnimationDuration = activity.getResources().getInteger(android.R.integer.config_shortAnimTime); + } + + @Override + public boolean hideHeaderView() { + mShowProgressBarOnly = false; + return super.hideHeaderView(); + } + + @Override + public boolean showHeaderView() { + // Workaround to avoid this bug https://github.com/chrisbanes/ActionBar-PullToRefresh/issues/265 + // Note, that also remove the alpha animation + resetContentLayoutAlpha(); + + boolean changeVis = mHeaderView.getVisibility() != View.VISIBLE; + mContentLayout.setVisibility(View.VISIBLE); + if (changeVis) { + mHeaderView.setVisibility(View.VISIBLE); + AnimatorSet animSet = new AnimatorSet(); + ObjectAnimator alphaAnim = ObjectAnimator.ofFloat(mHeaderView, "alpha", 0f, 1f); + ObjectAnimator transAnim = ObjectAnimator.ofFloat(mContentLayout, "translationY", + -mContentLayout.getHeight(), 10f); + animSet.playTogether(transAnim, alphaAnim); + animSet.play(alphaAnim); + animSet.setDuration(mAnimationDuration); + animSet.start(); + if (mShowProgressBarOnly) { + mContentLayout.setVisibility(View.INVISIBLE); + } + } + return changeVis; + } + + @Override + public void onPulled(float percentagePulled) { + super.onPulled(percentagePulled); + } + + private void resetContentLayoutAlpha() { + Compat.setAlpha(mContentLayout, 1f); + } + + @Override + public void onReset() { + super.onReset(); + // Reset the Content Layout + if (mContentLayout != null) { + Compat.setAlpha(mContentLayout, 1f); + mContentLayout.setVisibility(View.VISIBLE); + } + } + + @Override + public void onTopScrollChanged(boolean scrolledOnTop) { + if (mOnTopScrollChangedListener != null) { + mOnTopScrollChangedListener.onTopScrollChanged(scrolledOnTop); + } + } + + public void setOnTopScrollChangedListener(OnTopScrollChangedListener listener) { + mOnTopScrollChangedListener = listener; + } +} diff --git a/WordPressUtils/src/main/java/org/wordpress/android/util/ptr/PullToRefreshHelper.java b/WordPressUtils/src/main/java/org/wordpress/android/util/ptr/PullToRefreshHelper.java new file mode 100644 index 000000000000..3c7b4661955d --- /dev/null +++ b/WordPressUtils/src/main/java/org/wordpress/android/util/ptr/PullToRefreshHelper.java @@ -0,0 +1,142 @@ +package org.wordpress.android.util.ptr; + +import android.app.Activity; +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.content.SharedPreferences; +import android.content.SharedPreferences.Editor; +import android.preference.PreferenceManager; +import android.support.v4.content.LocalBroadcastManager; +import android.view.View; + +import org.wordpress.android.util.R; +import org.wordpress.android.util.ToastUtils; +import org.wordpress.android.util.ToastUtils.Duration; + +import java.lang.ref.WeakReference; +import java.util.Arrays; +import java.util.HashSet; +import java.util.Set; + +import uk.co.senab.actionbarpulltorefresh.library.ActionBarPullToRefresh; +import uk.co.senab.actionbarpulltorefresh.library.ActionBarPullToRefresh.SetupWizard; +import uk.co.senab.actionbarpulltorefresh.library.Options; +import uk.co.senab.actionbarpulltorefresh.library.PullToRefreshLayout; +import uk.co.senab.actionbarpulltorefresh.library.listeners.OnRefreshListener; +import uk.co.senab.actionbarpulltorefresh.library.viewdelegates.ViewDelegate; + +public class PullToRefreshHelper implements OnRefreshListener { + public static final String BROADCAST_ACTION_REFRESH_MENU_PRESSED = "REFRESH_MENU_PRESSED"; + private static final String REFRESH_BUTTON_HIT_COUNT = "REFRESH_BUTTON_HIT_COUNT"; + private static final Set TOAST_FREQUENCY = new HashSet(Arrays.asList(1, 5, 10, 20, 40, 80, 160, + 320, 640)); + private PullToRefreshHeaderTransformer mHeaderTransformer; + private PullToRefreshLayout mPullToRefreshLayout; + private RefreshListener mRefreshListener; + private WeakReference mActivityRef; + + public PullToRefreshHelper(Activity activity, PullToRefreshLayout pullToRefreshLayout, RefreshListener listener) { + init(activity, pullToRefreshLayout, listener, null); + } + + public PullToRefreshHelper(Activity activity, PullToRefreshLayout pullToRefreshLayout, RefreshListener listener, + java.lang.Class viewClass) { + init(activity, pullToRefreshLayout, listener, viewClass); + } + + public void init(Activity activity, PullToRefreshLayout pullToRefreshLayout, RefreshListener listener, + java.lang.Class viewClass) { + mActivityRef = new WeakReference(activity); + mRefreshListener = listener; + mPullToRefreshLayout = pullToRefreshLayout; + mHeaderTransformer = new PullToRefreshHeaderTransformer(); + SetupWizard setupWizard = ActionBarPullToRefresh.from(activity).options(Options.create().headerTransformer( + mHeaderTransformer).build()).allChildrenArePullable().listener(this); + if (viewClass != null) { + setupWizard.useViewDelegate(viewClass, new ViewDelegate() { + @Override + public boolean isReadyForPull(View view, float v, float v2) { + return true; + } + } + ); + } + setupWizard.setup(mPullToRefreshLayout); + } + + public void setRefreshing(boolean refreshing) { + mHeaderTransformer.setShowProgressBarOnly(refreshing); + mPullToRefreshLayout.setRefreshing(refreshing); + } + + public boolean isRefreshing() { + return mPullToRefreshLayout.isRefreshing(); + } + + @Override + public void onRefreshStarted(View view) { + mRefreshListener.onRefreshStarted(view); + } + + public interface RefreshListener { + public void onRefreshStarted(View view); + } + + public void setEnabled(boolean enabled) { + mPullToRefreshLayout.setEnabled(enabled); + } + + public void refreshAction() { + Activity activity = mActivityRef.get(); + if (activity == null) { + return; + } + setRefreshing(true); + mRefreshListener.onRefreshStarted(mPullToRefreshLayout); + SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(activity); + int refreshHits = preferences.getInt(REFRESH_BUTTON_HIT_COUNT, 0); + refreshHits += 1; + if (TOAST_FREQUENCY.contains(refreshHits)) { + ToastUtils.showToast(activity, R.string.ptr_tip_message, Duration.LONG); + } + Editor editor = preferences.edit(); + editor.putInt(REFRESH_BUTTON_HIT_COUNT, refreshHits); + editor.commit(); + } + + public void registerReceiver(Context context) { + if (context == null) { + return; + } + IntentFilter filter = new IntentFilter(); + filter.addAction(BROADCAST_ACTION_REFRESH_MENU_PRESSED); + LocalBroadcastManager lbm = LocalBroadcastManager.getInstance(context); + lbm.registerReceiver(mReceiver, filter); + } + + public void unregisterReceiver(Context context) { + if (context == null) { + return; + } + try { + LocalBroadcastManager lbm = LocalBroadcastManager.getInstance(context); + lbm.unregisterReceiver(mReceiver); + } catch (IllegalArgumentException e) { + // exception occurs if receiver already unregistered (safe to ignore) + } + } + + private final BroadcastReceiver mReceiver = new BroadcastReceiver() { + @Override + public void onReceive(Context context, Intent intent) { + if (intent == null || intent.getAction() == null) { + return; + } + if (intent.getAction().equals(BROADCAST_ACTION_REFRESH_MENU_PRESSED)) { + refreshAction(); + } + } + }; +} diff --git a/WordPressUtils/src/main/res/values/strings.xml b/WordPressUtils/src/main/res/values/strings.xml new file mode 100644 index 000000000000..2061ba880c10 --- /dev/null +++ b/WordPressUtils/src/main/res/values/strings.xml @@ -0,0 +1,4 @@ + + + Tip: Pull down to refresh + diff --git a/build.gradle b/build.gradle new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..0087cd3b18659b5577cf6ad3ef61f8eb9416ebba GIT binary patch literal 51348 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

          CflOl1i4(-2^1KXo)&I5QlgjRKFQgM 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 z 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 literal 0 HcmV?d00001 diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 000000000000..2bdda831e5af --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,6 @@ +#Wed Jul 09 11:48:51 CEST 2014 +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-1.11-all.zip diff --git a/gradlew b/gradlew new file mode 100755 index 000000000000..91a7e269e19d --- /dev/null +++ b/gradlew @@ -0,0 +1,164 @@ +#!/usr/bin/env bash + +############################################################################## +## +## Gradle start up script for UN*X +## +############################################################################## + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS="" + +APP_NAME="Gradle" +APP_BASE_NAME=`basename "$0"` + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD="maximum" + +warn ( ) { + echo "$*" +} + +die ( ) { + echo + echo "$*" + echo + exit 1 +} + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +case "`uname`" in + CYGWIN* ) + cygwin=true + ;; + Darwin* ) + darwin=true + ;; + MINGW* ) + msys=true + ;; +esac + +# For Cygwin, ensure paths are in UNIX format before anything is touched. +if $cygwin ; then + [ -n "$JAVA_HOME" ] && JAVA_HOME=`cygpath --unix "$JAVA_HOME"` +fi + +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +PRG="$0" +# Need this for relative symlinks. +while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi +done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" >&- +APP_HOME="`pwd -P`" +cd "$SAVED" >&- + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD="java" + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if [ "$cygwin" = "false" -a "$darwin" = "false" ] ; then + MAX_FD_LIMIT=`ulimit -H -n` + if [ $? -eq 0 ] ; then + if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then + MAX_FD="$MAX_FD_LIMIT" + fi + ulimit -n $MAX_FD + if [ $? -ne 0 ] ; then + warn "Could not set maximum file descriptor limit: $MAX_FD" + fi + else + warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" + fi +fi + +# For Darwin, add options to specify how the application appears in the dock +if $darwin; then + GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" +fi + +# For Cygwin, switch paths to Windows format before running java +if $cygwin ; then + APP_HOME=`cygpath --path --mixed "$APP_HOME"` + CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` + + # We build the pattern for arguments to be converted via cygpath + ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` + SEP="" + for dir in $ROOTDIRSRAW ; do + ROOTDIRS="$ROOTDIRS$SEP$dir" + SEP="|" + done + OURCYGPATTERN="(^($ROOTDIRS))" + # Add a user-defined pattern to the cygpath arguments + if [ "$GRADLE_CYGPATTERN" != "" ] ; then + OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" + fi + # Now convert the arguments - kludge to limit ourselves to /bin/sh + i=0 + for arg in "$@" ; do + CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` + CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option + + if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition + eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` + else + eval `echo args$i`="\"$arg\"" + fi + i=$((i+1)) + done + case $i in + (0) set -- ;; + (1) set -- "$args0" ;; + (2) set -- "$args0" "$args1" ;; + (3) set -- "$args0" "$args1" "$args2" ;; + (4) set -- "$args0" "$args1" "$args2" "$args3" ;; + (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; + (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; + (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; + (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; + (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; + esac +fi + +# Split up the JVM_OPTS And GRADLE_OPTS values into an array, following the shell quoting and substitution rules +function splitJvmOpts() { + JVM_OPTS=("$@") +} +eval splitJvmOpts $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS +JVM_OPTS[${#JVM_OPTS[*]}]="-Dorg.gradle.appname=$APP_BASE_NAME" + +exec "$JAVACMD" "${JVM_OPTS[@]}" -classpath "$CLASSPATH" org.gradle.wrapper.GradleWrapperMain "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 000000000000..8a0b282aa688 --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,90 @@ +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS= + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto init + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto init + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:init +@rem Get command-line arguments, handling Windowz variants + +if not "%OS%" == "Windows_NT" goto win9xME_args +if "%@eval[2+2]" == "4" goto 4NT_args + +:win9xME_args +@rem Slurp the command line arguments. +set CMD_LINE_ARGS= +set _SKIP=2 + +:win9xME_args_slurp +if "x%~1" == "x" goto execute + +set CMD_LINE_ARGS=%* +goto execute + +:4NT_args +@rem Get arguments from the 4NT Shell from JP Software +set CMD_LINE_ARGS=%$ + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/settings.gradle b/settings.gradle new file mode 100644 index 000000000000..3519745edd00 --- /dev/null +++ b/settings.gradle @@ -0,0 +1 @@ +include ':WordPressUtils' \ No newline at end of file From d63c18893ef5b4bed052a0465af2d472f362ba3e Mon Sep 17 00:00:00 2001 From: Maxime Biais Date: Tue, 29 Jul 2014 20:38:48 +0200 Subject: [PATCH 27/31] import libs/utis --- gradlew.bat | 180 ++++++++++++++++++++++++++-------------------------- 1 file changed, 90 insertions(+), 90 deletions(-) diff --git a/gradlew.bat b/gradlew.bat index aec99730b4e8..8a0b282aa688 100644 --- a/gradlew.bat +++ b/gradlew.bat @@ -1,90 +1,90 @@ -@if "%DEBUG%" == "" @echo off -@rem ########################################################################## -@rem -@rem Gradle startup script for Windows -@rem -@rem ########################################################################## - -@rem Set local scope for the variables with windows NT shell -if "%OS%"=="Windows_NT" setlocal - -@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -set DEFAULT_JVM_OPTS= - -set DIRNAME=%~dp0 -if "%DIRNAME%" == "" set DIRNAME=. -set APP_BASE_NAME=%~n0 -set APP_HOME=%DIRNAME% - -@rem Find java.exe -if defined JAVA_HOME goto findJavaFromJavaHome - -set JAVA_EXE=java.exe -%JAVA_EXE% -version >NUL 2>&1 -if "%ERRORLEVEL%" == "0" goto init - -echo. -echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. -echo. -echo Please set the JAVA_HOME variable in your environment to match the -echo location of your Java installation. - -goto fail - -:findJavaFromJavaHome -set JAVA_HOME=%JAVA_HOME:"=% -set JAVA_EXE=%JAVA_HOME%/bin/java.exe - -if exist "%JAVA_EXE%" goto init - -echo. -echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% -echo. -echo Please set the JAVA_HOME variable in your environment to match the -echo location of your Java installation. - -goto fail - -:init -@rem Get command-line arguments, handling Windowz variants - -if not "%OS%" == "Windows_NT" goto win9xME_args -if "%@eval[2+2]" == "4" goto 4NT_args - -:win9xME_args -@rem Slurp the command line arguments. -set CMD_LINE_ARGS= -set _SKIP=2 - -:win9xME_args_slurp -if "x%~1" == "x" goto execute - -set CMD_LINE_ARGS=%* -goto execute - -:4NT_args -@rem Get arguments from the 4NT Shell from JP Software -set CMD_LINE_ARGS=%$ - -:execute -@rem Setup the command line - -set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar - -@rem Execute Gradle -"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% - -:end -@rem End local scope for the variables with windows NT shell -if "%ERRORLEVEL%"=="0" goto mainEnd - -:fail -rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of -rem the _cmd.exe /c_ return code! -if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 -exit /b 1 - -:mainEnd -if "%OS%"=="Windows_NT" endlocal - -:omega +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS= + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto init + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto init + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:init +@rem Get command-line arguments, handling Windowz variants + +if not "%OS%" == "Windows_NT" goto win9xME_args +if "%@eval[2+2]" == "4" goto 4NT_args + +:win9xME_args +@rem Slurp the command line arguments. +set CMD_LINE_ARGS= +set _SKIP=2 + +:win9xME_args_slurp +if "x%~1" == "x" goto execute + +set CMD_LINE_ARGS=%* +goto execute + +:4NT_args +@rem Get arguments from the 4NT Shell from JP Software +set CMD_LINE_ARGS=%$ + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega From 35732c4fc7ecc9615a77990e2641492676b28158 Mon Sep 17 00:00:00 2001 From: Maxime Biais Date: Tue, 29 Jul 2014 20:45:01 +0200 Subject: [PATCH 28/31] init utils readme --- README.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 README.md diff --git a/README.md b/README.md new file mode 100644 index 000000000000..5bfc36560535 --- /dev/null +++ b/README.md @@ -0,0 +1,5 @@ +# WordPress-Utils-Android + +Part of the [WordPress-Android] project. + +[WordPress-Android]: https://github.com/wordpress-mobile/WordPress-Android From 19d4c24e7274342c2a765ca95f635533e8b1864c Mon Sep 17 00:00:00 2001 From: Danilo Ercoli Date: Wed, 30 Jul 2014 17:44:15 +0200 Subject: [PATCH 29/31] Text view that auto adjusts text size to fit within the view.If the text size equals the minimum text size and still does not fit, append with an ellipsis. --- .../android/util/AutoResizeTextView.java | 299 ++++++++++++++++++ 1 file changed, 299 insertions(+) create mode 100644 WordPressUtils/src/main/java/org/wordpress/android/util/AutoResizeTextView.java diff --git a/WordPressUtils/src/main/java/org/wordpress/android/util/AutoResizeTextView.java b/WordPressUtils/src/main/java/org/wordpress/android/util/AutoResizeTextView.java new file mode 100644 index 000000000000..7da8b84c45f1 --- /dev/null +++ b/WordPressUtils/src/main/java/org/wordpress/android/util/AutoResizeTextView.java @@ -0,0 +1,299 @@ +package org.wordpress.android.util; + +import android.content.Context; +import android.text.Layout; +import android.text.StaticLayout; +import android.text.TextPaint; +import android.util.AttributeSet; +import android.util.TypedValue; +import android.widget.TextView; + +/** + * Text view that auto adjusts text size to fit within the view. + * If the text size equals the minimum text size and still does not + * fit, append with an ellipsis. + * + * See http://stackoverflow.com/a/5535672 + * + */ +public class AutoResizeTextView extends TextView { + // Minimum text size for this text view + public static final float MIN_TEXT_SIZE = 20; + + // Interface for resize notifications + public interface OnTextResizeListener { + void onTextResize(TextView textView, float oldSize, float newSize); + } + + // Our ellipse string + private static final String M_ELLIPSIS = "..."; + + // Registered resize listener + private OnTextResizeListener mTextResizeListener; + + // Flag for text and/or size changes to force a resize + private boolean mNeedsResize = false; + + // Text size that is set from code. This acts as a starting point for resizing + private float mTextSize; + + // Temporary upper bounds on the starting text size + private float mMaxTextSize = 0; + + // Lower bounds for text size + private float mMinTextSize = MIN_TEXT_SIZE; + + // Text view line spacing multiplier + private float mSpacingMult = 1.0f; + + // Text view additional line spacing + private float mSpacingAdd = 0.0f; + + // Add ellipsis to text that overflows at the smallest text size + private boolean mAddEllipsis = true; + + // Default constructor override + public AutoResizeTextView(Context context) { + this(context, null); + } + + // Default constructor when inflating from XML file + public AutoResizeTextView(Context context, AttributeSet attrs) { + this(context, attrs, 0); + } + + // Default constructor override + public AutoResizeTextView(Context context, AttributeSet attrs, int defStyle) { + super(context, attrs, defStyle); + mTextSize = getTextSize(); + } + + /** + * When text changes, set the force resize flag to true and reset the text size. + */ + @Override + protected void onTextChanged(final CharSequence text, final int start, final int before, final int after) { + mNeedsResize = true; + // Since this view may be reused, it is good to reset the text size + resetTextSize(); + } + + /** + * If the text view size changed, set the force resize flag to true + */ + @Override + protected void onSizeChanged(int w, int h, int oldw, int oldh) { + if (w != oldw || h != oldh) { + mNeedsResize = true; + } + } + + /** + * Register listener to receive resize notifications + * @param listener + */ + public void setOnResizeListener(OnTextResizeListener listener) { + mTextResizeListener = listener; + } + + /** + * Override the set text size to update our internal reference values + */ + @Override + public void setTextSize(float size) { + super.setTextSize(size); + mTextSize = getTextSize(); + } + + /** + * Override the set text size to update our internal reference values + */ + @Override + public void setTextSize(int unit, float size) { + super.setTextSize(unit, size); + mTextSize = getTextSize(); + } + + /** + * Override the set line spacing to update our internal reference values + */ + @Override + public void setLineSpacing(float add, float mult) { + super.setLineSpacing(add, mult); + mSpacingMult = mult; + mSpacingAdd = add; + } + + /** + * Set the upper text size limit and invalidate the view + * @param maxTextSize + */ + public void setMaxTextSize(float maxTextSize) { + mMaxTextSize = maxTextSize; + requestLayout(); + invalidate(); + } + + /** + * Return upper text size limit + * @return + */ + public float getMaxTextSize() { + return mMaxTextSize; + } + + /** + * Set the lower text size limit and invalidate the view + * @param minTextSize + */ + public void setMinTextSize(float minTextSize) { + mMinTextSize = minTextSize; + requestLayout(); + invalidate(); + } + + /** + * Return lower text size limit + * @return + */ + public float getMinTextSize() { + return mMinTextSize; + } + + /** + * Set flag to add ellipsis to text that overflows at the smallest text size + * @param addEllipsis + */ + public void setAddEllipsis(boolean addEllipsis) { + mAddEllipsis = addEllipsis; + } + + /** + * Return flag to add ellipsis to text that overflows at the smallest text size + * @return + */ + public boolean getAddEllipsis() { + return mAddEllipsis; + } + + /** + * Reset the text to the original size + */ + public void resetTextSize() { + if (mTextSize > 0) { + super.setTextSize(TypedValue.COMPLEX_UNIT_PX, mTextSize); + mMaxTextSize = mTextSize; + } + } + + /** + * Resize text after measuring + */ + @Override + protected void onLayout(boolean changed, int left, int top, int right, int bottom) { + if (changed || mNeedsResize) { + int widthLimit = (right - left) - getCompoundPaddingLeft() - getCompoundPaddingRight(); + int heightLimit = (bottom - top) - getCompoundPaddingBottom() - getCompoundPaddingTop(); + resizeText(widthLimit, heightLimit); + } + super.onLayout(changed, left, top, right, bottom); + } + + /** + * Resize the text size with default width and height + */ + public void resizeText() { + int heightLimit = getHeight() - getPaddingBottom() - getPaddingTop(); + int widthLimit = getWidth() - getPaddingLeft() - getPaddingRight(); + resizeText(widthLimit, heightLimit); + } + + /** + * Resize the text size with specified width and height + * @param width + * @param height + */ + public void resizeText(int width, int height) { + CharSequence text = getText(); + // Do not resize if the view does not have dimensions or there is no text + if (text == null || text.length() == 0 || height <= 0 || width <= 0 || mTextSize == 0) { + return; + } + + // Get the text view's paint object + TextPaint textPaint = getPaint(); + + // Store the current text size + float oldTextSize = textPaint.getTextSize(); + // If there is a max text size set, use the lesser of that and the default text size + float targetTextSize = mMaxTextSize > 0 ? Math.min(mTextSize, mMaxTextSize) : mTextSize; + + // Get the required text height + int textHeight = getTextHeight(text, textPaint, width, targetTextSize); + + // Until we either fit within our text view or we had reached our min text size, incrementally try smaller sizes + while (textHeight > height && targetTextSize > mMinTextSize) { + targetTextSize = Math.max(targetTextSize - 2, mMinTextSize); + textHeight = getTextHeight(text, textPaint, width, targetTextSize); + } + + // If we had reached our minimum text size and still don't fit, append an ellipsis + if (mAddEllipsis && targetTextSize == mMinTextSize && textHeight > height) { + // Draw using a static layout + // modified: use a copy of TextPaint for measuring + TextPaint paint = new TextPaint(textPaint); + // Draw using a static layout + StaticLayout layout = new StaticLayout(text, paint, width, Layout.Alignment.ALIGN_NORMAL, + mSpacingMult, mSpacingAdd, false); + // Check that we have a least one line of rendered text + if (layout.getLineCount() > 0) { + // Since the line at the specific vertical position would be cut off, + // we must trim up to the previous line + int lastLine = layout.getLineForVertical(height) - 1; + // If the text would not even fit on a single line, clear it + if (lastLine < 0) { + setText(""); + } else { + // Otherwise, trim to the previous line and add an ellipsis + int start = layout.getLineStart(lastLine); + int end = layout.getLineEnd(lastLine); + float lineWidth = layout.getLineWidth(lastLine); + float ellipseWidth = paint.measureText(M_ELLIPSIS); + + // Trim characters off until we have enough room to draw the ellipsis + while (width < lineWidth + ellipseWidth) { + lineWidth = paint.measureText(text.subSequence(start, --end + 1).toString()); + } + setText(text.subSequence(0, end) + M_ELLIPSIS); + } + } + } + + // Some devices try to auto adjust line spacing, so force default line spacing + // and invalidate the layout as a side effect + setTextSize(TypedValue.COMPLEX_UNIT_PX, targetTextSize); + setLineSpacing(mSpacingAdd, mSpacingMult); + + // Notify the listener if registered + if (mTextResizeListener != null) { + mTextResizeListener.onTextResize(this, oldTextSize, targetTextSize); + } + + // Reset force resize flag + mNeedsResize = false; + } + + // Set the text size of the text paint object and use a static layout to render text off screen before measuring + private int getTextHeight(CharSequence source, TextPaint paint, int width, float textSize) { + // modified: make a copy of the original TextPaint object for measuring + // (apparently the object gets modified while measuring, see also the + // docs for TextView.getPaint() (which states to access it read-only) + TextPaint paintCopy = new TextPaint(paint); + // Update the text paint object + paintCopy.setTextSize(textSize); + // Measure using a static layout + StaticLayout layout = new StaticLayout(source, paintCopy, width, Layout.Alignment.ALIGN_NORMAL, + mSpacingMult, mSpacingAdd, true); + return layout.getHeight(); + } +} From 914a978ae452ebc1b188ae5beacafcfeea01e7e5 Mon Sep 17 00:00:00 2001 From: Danilo Ercoli Date: Thu, 31 Jul 2014 00:03:43 +0200 Subject: [PATCH 30/31] Use the unicode version of the Horizontal ellipsis char. Restrict the visibility of some methods to private. --- .../org/wordpress/android/util/AutoResizeTextView.java | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/WordPressUtils/src/main/java/org/wordpress/android/util/AutoResizeTextView.java b/WordPressUtils/src/main/java/org/wordpress/android/util/AutoResizeTextView.java index 7da8b84c45f1..5f55f6058a97 100644 --- a/WordPressUtils/src/main/java/org/wordpress/android/util/AutoResizeTextView.java +++ b/WordPressUtils/src/main/java/org/wordpress/android/util/AutoResizeTextView.java @@ -18,15 +18,15 @@ */ public class AutoResizeTextView extends TextView { // Minimum text size for this text view - public static final float MIN_TEXT_SIZE = 20; + private static final float MIN_TEXT_SIZE = 20; // Interface for resize notifications public interface OnTextResizeListener { void onTextResize(TextView textView, float oldSize, float newSize); } - // Our ellipse string - private static final String M_ELLIPSIS = "..."; + // Our ellipse string - Unicode Character 'HORIZONTAL ELLIPSIS' + private static final String M_ELLIPSIS = "\u2026"; // Registered resize listener private OnTextResizeListener mTextResizeListener; @@ -179,7 +179,7 @@ public boolean getAddEllipsis() { /** * Reset the text to the original size */ - public void resetTextSize() { + private void resetTextSize() { if (mTextSize > 0) { super.setTextSize(TypedValue.COMPLEX_UNIT_PX, mTextSize); mMaxTextSize = mTextSize; From 3e3d431e09345ca9f7e2006cb9b9c3f683862a41 Mon Sep 17 00:00:00 2001 From: Danilo Ercoli Date: Thu, 31 Jul 2014 16:22:59 +0200 Subject: [PATCH 31/31] Bump libs/utils version number. --- WordPressUtils/build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/WordPressUtils/build.gradle b/WordPressUtils/build.gradle index da68c80de30e..1b1bd5a4457f 100644 --- a/WordPressUtils/build.gradle +++ b/WordPressUtils/build.gradle @@ -32,7 +32,7 @@ android { defaultConfig { applicationId "org.wordpress.android.util" - versionName "1.0.2" + versionName "1.1.0" versionCode 1 minSdkVersion 14 targetSdkVersion 19