From cc513446b9726a841a38eab88477b9642ce7c723 Mon Sep 17 00:00:00 2001 From: "heavyrain.lee" Date: Fri, 18 Jan 2019 21:41:21 +0800 Subject: [PATCH] commit push server --- .gitignore | 1 + apns/develop.p12 | Bin 0 -> 3177 bytes apns/product.p12 | Bin 0 -> 3305 bytes apns/voip.p12 | Bin 0 -> 3345 bytes apns/wfdemo_apns.p12 | Bin 0 -> 3261 bytes apns/wfdemo_voip.p12 | Bin 0 -> 3301 bytes mvnw | 225 ++++++ mvnw.cmd | 143 ++++ pom.xml | 147 ++++ .../cn/wildfirechat/push/PushApplication.java | 11 + .../cn/wildfirechat/push/PushController.java | 28 + .../cn/wildfirechat/push/PushMessage.java | 161 ++++ .../cn/wildfirechat/push/PushMessageType.java | 7 + .../push/android/AndroidPushService.java | 7 + .../push/android/AndroidPushServiceImpl.java | 45 ++ .../push/android/AndroidPushType.java | 7 + .../push/android/hms/HMSConfig.java | 29 + .../push/android/hms/HMSPush.java | 136 ++++ .../push/android/meizu/MeiZuConfig.java | 29 + .../push/android/meizu/MeiZuPush.java | 68 ++ .../push/android/xiaomi/XiaomiConfig.java | 20 + .../push/android/xiaomi/XiaomiPush.java | 71 ++ .../cn/wildfirechat/push/ios/ApnsConfig.java | 86 ++ .../cn/wildfirechat/push/ios/ApnsServer.java | 187 +++++ .../wildfirechat/push/ios/IOSPushService.java | 7 + .../push/ios/IOSPushServiceImpl.java | 22 + .../cn/wildfirechat/push/ios/IOSPushType.java | 6 + src/main/java/com/notnoop/apns/APNS.java | 57 ++ .../java/com/notnoop/apns/ApnsDelegate.java | 92 +++ .../com/notnoop/apns/ApnsDelegateAdapter.java | 52 ++ .../com/notnoop/apns/ApnsNotification.java | 76 ++ .../java/com/notnoop/apns/ApnsService.java | 140 ++++ .../com/notnoop/apns/ApnsServiceBuilder.java | 760 ++++++++++++++++++ .../java/com/notnoop/apns/DeliveryError.java | 81 ++ .../apns/EnhancedApnsNotification.java | 191 +++++ .../java/com/notnoop/apns/PayloadBuilder.java | 535 ++++++++++++ .../com/notnoop/apns/ReconnectPolicy.java | 118 +++ .../notnoop/apns/SimpleApnsNotification.java | 172 ++++ .../apns/StartSendingApnsDelegate.java | 47 ++ .../apns/internal/AbstractApnsService.java | 90 +++ .../notnoop/apns/internal/ApnsConnection.java | 52 ++ .../apns/internal/ApnsConnectionImpl.java | 412 ++++++++++ .../apns/internal/ApnsFeedbackConnection.java | 121 +++ .../apns/internal/ApnsPooledConnection.java | 121 +++ .../apns/internal/ApnsServiceImpl.java | 59 ++ .../apns/internal/BatchApnsService.java | 143 ++++ .../apns/internal/QueuedApnsService.java | 126 +++ .../apns/internal/ReconnectPolicies.java | 67 ++ .../apns/internal/SSLContextBuilder.java | 179 +++++ .../apns/internal/TlsTunnelBuilder.java | 147 ++++ .../com/notnoop/apns/internal/Utilities.java | 296 +++++++ .../ApnsDeliveryErrorException.java | 61 ++ .../com/notnoop/exceptions/ApnsException.java | 44 + .../notnoop/exceptions/InvalidSSLConfig.java | 64 ++ .../exceptions/NetworkIOException.java | 69 ++ .../exceptions/RuntimeIOException.java | 50 ++ src/main/libs/MiPush_SDK_Server_2_2_19.jar | Bin 0 -> 94365 bytes src/main/resources/apns.properties | 9 + src/main/resources/application.properties | 1 + src/main/resources/hms.properties | 2 + src/main/resources/meizu.properties | 2 + src/main/resources/xiaomi.properties | 1 + .../push/PushApplicationTests.java | 16 + 63 files changed, 5896 insertions(+) create mode 100644 .gitignore create mode 100644 apns/develop.p12 create mode 100644 apns/product.p12 create mode 100644 apns/voip.p12 create mode 100644 apns/wfdemo_apns.p12 create mode 100755 apns/wfdemo_voip.p12 create mode 100755 mvnw create mode 100644 mvnw.cmd create mode 100644 pom.xml create mode 100644 src/main/java/cn/wildfirechat/push/PushApplication.java create mode 100644 src/main/java/cn/wildfirechat/push/PushController.java create mode 100644 src/main/java/cn/wildfirechat/push/PushMessage.java create mode 100644 src/main/java/cn/wildfirechat/push/PushMessageType.java create mode 100644 src/main/java/cn/wildfirechat/push/android/AndroidPushService.java create mode 100644 src/main/java/cn/wildfirechat/push/android/AndroidPushServiceImpl.java create mode 100644 src/main/java/cn/wildfirechat/push/android/AndroidPushType.java create mode 100644 src/main/java/cn/wildfirechat/push/android/hms/HMSConfig.java create mode 100644 src/main/java/cn/wildfirechat/push/android/hms/HMSPush.java create mode 100644 src/main/java/cn/wildfirechat/push/android/meizu/MeiZuConfig.java create mode 100644 src/main/java/cn/wildfirechat/push/android/meizu/MeiZuPush.java create mode 100644 src/main/java/cn/wildfirechat/push/android/xiaomi/XiaomiConfig.java create mode 100644 src/main/java/cn/wildfirechat/push/android/xiaomi/XiaomiPush.java create mode 100644 src/main/java/cn/wildfirechat/push/ios/ApnsConfig.java create mode 100644 src/main/java/cn/wildfirechat/push/ios/ApnsServer.java create mode 100644 src/main/java/cn/wildfirechat/push/ios/IOSPushService.java create mode 100644 src/main/java/cn/wildfirechat/push/ios/IOSPushServiceImpl.java create mode 100644 src/main/java/cn/wildfirechat/push/ios/IOSPushType.java create mode 100755 src/main/java/com/notnoop/apns/APNS.java create mode 100755 src/main/java/com/notnoop/apns/ApnsDelegate.java create mode 100755 src/main/java/com/notnoop/apns/ApnsDelegateAdapter.java create mode 100755 src/main/java/com/notnoop/apns/ApnsNotification.java create mode 100755 src/main/java/com/notnoop/apns/ApnsService.java create mode 100755 src/main/java/com/notnoop/apns/ApnsServiceBuilder.java create mode 100755 src/main/java/com/notnoop/apns/DeliveryError.java create mode 100755 src/main/java/com/notnoop/apns/EnhancedApnsNotification.java create mode 100755 src/main/java/com/notnoop/apns/PayloadBuilder.java create mode 100755 src/main/java/com/notnoop/apns/ReconnectPolicy.java create mode 100755 src/main/java/com/notnoop/apns/SimpleApnsNotification.java create mode 100755 src/main/java/com/notnoop/apns/StartSendingApnsDelegate.java create mode 100755 src/main/java/com/notnoop/apns/internal/AbstractApnsService.java create mode 100755 src/main/java/com/notnoop/apns/internal/ApnsConnection.java create mode 100755 src/main/java/com/notnoop/apns/internal/ApnsConnectionImpl.java create mode 100755 src/main/java/com/notnoop/apns/internal/ApnsFeedbackConnection.java create mode 100755 src/main/java/com/notnoop/apns/internal/ApnsPooledConnection.java create mode 100755 src/main/java/com/notnoop/apns/internal/ApnsServiceImpl.java create mode 100755 src/main/java/com/notnoop/apns/internal/BatchApnsService.java create mode 100755 src/main/java/com/notnoop/apns/internal/QueuedApnsService.java create mode 100755 src/main/java/com/notnoop/apns/internal/ReconnectPolicies.java create mode 100755 src/main/java/com/notnoop/apns/internal/SSLContextBuilder.java create mode 100755 src/main/java/com/notnoop/apns/internal/TlsTunnelBuilder.java create mode 100755 src/main/java/com/notnoop/apns/internal/Utilities.java create mode 100755 src/main/java/com/notnoop/exceptions/ApnsDeliveryErrorException.java create mode 100755 src/main/java/com/notnoop/exceptions/ApnsException.java create mode 100755 src/main/java/com/notnoop/exceptions/InvalidSSLConfig.java create mode 100755 src/main/java/com/notnoop/exceptions/NetworkIOException.java create mode 100755 src/main/java/com/notnoop/exceptions/RuntimeIOException.java create mode 100644 src/main/libs/MiPush_SDK_Server_2_2_19.jar create mode 100644 src/main/resources/apns.properties create mode 100644 src/main/resources/application.properties create mode 100644 src/main/resources/hms.properties create mode 100644 src/main/resources/meizu.properties create mode 100644 src/main/resources/xiaomi.properties create mode 100644 src/test/java/cn/wildfirechat/push/PushApplicationTests.java diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..eb5a316 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +target diff --git a/apns/develop.p12 b/apns/develop.p12 new file mode 100644 index 0000000000000000000000000000000000000000..9c18df2f42a0946dff3efa4d7c2892503b2e047a GIT binary patch literal 3177 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 TY=l8;A1!%{EHkw_Gj$=O91d&V5 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 Z3+N1};U!%KfZ(G5at9n#|A`g=_%8qiE!qG8 literal 0 HcmV?d00001 diff --git a/mvnw b/mvnw new file mode 100755 index 0000000..5bf251c --- /dev/null +++ b/mvnw @@ -0,0 +1,225 @@ +#!/bin/sh +# ---------------------------------------------------------------------------- +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you 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. +# ---------------------------------------------------------------------------- + +# ---------------------------------------------------------------------------- +# Maven2 Start Up Batch script +# +# Required ENV vars: +# ------------------ +# JAVA_HOME - location of a JDK home dir +# +# Optional ENV vars +# ----------------- +# M2_HOME - location of maven2's installed home dir +# MAVEN_OPTS - parameters passed to the Java VM when running Maven +# e.g. to debug Maven itself, use +# set MAVEN_OPTS=-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=y,address=8000 +# MAVEN_SKIP_RC - flag to disable loading of mavenrc files +# ---------------------------------------------------------------------------- + +if [ -z "$MAVEN_SKIP_RC" ] ; then + + if [ -f /etc/mavenrc ] ; then + . /etc/mavenrc + fi + + if [ -f "$HOME/.mavenrc" ] ; then + . "$HOME/.mavenrc" + fi + +fi + +# OS specific support. $var _must_ be set to either true or false. +cygwin=false; +darwin=false; +mingw=false +case "`uname`" in + CYGWIN*) cygwin=true ;; + MINGW*) mingw=true;; + Darwin*) darwin=true + # Use /usr/libexec/java_home if available, otherwise fall back to /Library/Java/Home + # See https://developer.apple.com/library/mac/qa/qa1170/_index.html + if [ -z "$JAVA_HOME" ]; then + if [ -x "/usr/libexec/java_home" ]; then + export JAVA_HOME="`/usr/libexec/java_home`" + else + export JAVA_HOME="/Library/Java/Home" + fi + fi + ;; +esac + +if [ -z "$JAVA_HOME" ] ; then + if [ -r /etc/gentoo-release ] ; then + JAVA_HOME=`java-config --jre-home` + fi +fi + +if [ -z "$M2_HOME" ] ; then + ## resolve links - $0 may be a link to maven's home + 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 + + saveddir=`pwd` + + M2_HOME=`dirname "$PRG"`/.. + + # make it fully qualified + M2_HOME=`cd "$M2_HOME" && pwd` + + cd "$saveddir" + # echo Using m2 at $M2_HOME +fi + +# For Cygwin, ensure paths are in UNIX format before anything is touched +if $cygwin ; then + [ -n "$M2_HOME" ] && + M2_HOME=`cygpath --unix "$M2_HOME"` + [ -n "$JAVA_HOME" ] && + JAVA_HOME=`cygpath --unix "$JAVA_HOME"` + [ -n "$CLASSPATH" ] && + CLASSPATH=`cygpath --path --unix "$CLASSPATH"` +fi + +# For Migwn, ensure paths are in UNIX format before anything is touched +if $mingw ; then + [ -n "$M2_HOME" ] && + M2_HOME="`(cd "$M2_HOME"; pwd)`" + [ -n "$JAVA_HOME" ] && + JAVA_HOME="`(cd "$JAVA_HOME"; pwd)`" + # TODO classpath? +fi + +if [ -z "$JAVA_HOME" ]; then + javaExecutable="`which javac`" + if [ -n "$javaExecutable" ] && ! [ "`expr \"$javaExecutable\" : '\([^ ]*\)'`" = "no" ]; then + # readlink(1) is not available as standard on Solaris 10. + readLink=`which readlink` + if [ ! `expr "$readLink" : '\([^ ]*\)'` = "no" ]; then + if $darwin ; then + javaHome="`dirname \"$javaExecutable\"`" + javaExecutable="`cd \"$javaHome\" && pwd -P`/javac" + else + javaExecutable="`readlink -f \"$javaExecutable\"`" + fi + javaHome="`dirname \"$javaExecutable\"`" + javaHome=`expr "$javaHome" : '\(.*\)/bin'` + JAVA_HOME="$javaHome" + export JAVA_HOME + fi + fi +fi + +if [ -z "$JAVACMD" ] ; then + 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 + else + JAVACMD="`which java`" + fi +fi + +if [ ! -x "$JAVACMD" ] ; then + echo "Error: JAVA_HOME is not defined correctly." >&2 + echo " We cannot execute $JAVACMD" >&2 + exit 1 +fi + +if [ -z "$JAVA_HOME" ] ; then + echo "Warning: JAVA_HOME environment variable is not set." +fi + +CLASSWORLDS_LAUNCHER=org.codehaus.plexus.classworlds.launcher.Launcher + +# traverses directory structure from process work directory to filesystem root +# first directory with .mvn subdirectory is considered project base directory +find_maven_basedir() { + + if [ -z "$1" ] + then + echo "Path not specified to find_maven_basedir" + return 1 + fi + + basedir="$1" + wdir="$1" + while [ "$wdir" != '/' ] ; do + if [ -d "$wdir"/.mvn ] ; then + basedir=$wdir + break + fi + # workaround for JBEAP-8937 (on Solaris 10/Sparc) + if [ -d "${wdir}" ]; then + wdir=`cd "$wdir/.."; pwd` + fi + # end of workaround + done + echo "${basedir}" +} + +# concatenates all lines of a file +concat_lines() { + if [ -f "$1" ]; then + echo "$(tr -s '\n' ' ' < "$1")" + fi +} + +BASE_DIR=`find_maven_basedir "$(pwd)"` +if [ -z "$BASE_DIR" ]; then + exit 1; +fi + +export MAVEN_PROJECTBASEDIR=${MAVEN_BASEDIR:-"$BASE_DIR"} +echo $MAVEN_PROJECTBASEDIR +MAVEN_OPTS="$(concat_lines "$MAVEN_PROJECTBASEDIR/.mvn/jvm.config") $MAVEN_OPTS" + +# For Cygwin, switch paths to Windows format before running java +if $cygwin; then + [ -n "$M2_HOME" ] && + M2_HOME=`cygpath --path --windows "$M2_HOME"` + [ -n "$JAVA_HOME" ] && + JAVA_HOME=`cygpath --path --windows "$JAVA_HOME"` + [ -n "$CLASSPATH" ] && + CLASSPATH=`cygpath --path --windows "$CLASSPATH"` + [ -n "$MAVEN_PROJECTBASEDIR" ] && + MAVEN_PROJECTBASEDIR=`cygpath --path --windows "$MAVEN_PROJECTBASEDIR"` +fi + +WRAPPER_LAUNCHER=org.apache.maven.wrapper.MavenWrapperMain + +exec "$JAVACMD" \ + $MAVEN_OPTS \ + -classpath "$MAVEN_PROJECTBASEDIR/.mvn/wrapper/maven-wrapper.jar" \ + "-Dmaven.home=${M2_HOME}" "-Dmaven.multiModuleProjectDirectory=${MAVEN_PROJECTBASEDIR}" \ + ${WRAPPER_LAUNCHER} $MAVEN_CONFIG "$@" diff --git a/mvnw.cmd b/mvnw.cmd new file mode 100644 index 0000000..019bd74 --- /dev/null +++ b/mvnw.cmd @@ -0,0 +1,143 @@ +@REM ---------------------------------------------------------------------------- +@REM Licensed to the Apache Software Foundation (ASF) under one +@REM or more contributor license agreements. See the NOTICE file +@REM distributed with this work for additional information +@REM regarding copyright ownership. The ASF licenses this file +@REM to you under the Apache License, Version 2.0 (the +@REM "License"); you may not use this file except in compliance +@REM with the License. You may obtain a copy of the License at +@REM +@REM http://www.apache.org/licenses/LICENSE-2.0 +@REM +@REM Unless required by applicable law or agreed to in writing, +@REM software distributed under the License is distributed on an +@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +@REM KIND, either express or implied. See the License for the +@REM specific language governing permissions and limitations +@REM under the License. +@REM ---------------------------------------------------------------------------- + +@REM ---------------------------------------------------------------------------- +@REM Maven2 Start Up Batch script +@REM +@REM Required ENV vars: +@REM JAVA_HOME - location of a JDK home dir +@REM +@REM Optional ENV vars +@REM M2_HOME - location of maven2's installed home dir +@REM MAVEN_BATCH_ECHO - set to 'on' to enable the echoing of the batch commands +@REM MAVEN_BATCH_PAUSE - set to 'on' to wait for a key stroke before ending +@REM MAVEN_OPTS - parameters passed to the Java VM when running Maven +@REM e.g. to debug Maven itself, use +@REM set MAVEN_OPTS=-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=y,address=8000 +@REM MAVEN_SKIP_RC - flag to disable loading of mavenrc files +@REM ---------------------------------------------------------------------------- + +@REM Begin all REM lines with '@' in case MAVEN_BATCH_ECHO is 'on' +@echo off +@REM enable echoing my setting MAVEN_BATCH_ECHO to 'on' +@if "%MAVEN_BATCH_ECHO%" == "on" echo %MAVEN_BATCH_ECHO% + +@REM set %HOME% to equivalent of $HOME +if "%HOME%" == "" (set "HOME=%HOMEDRIVE%%HOMEPATH%") + +@REM Execute a user defined script before this one +if not "%MAVEN_SKIP_RC%" == "" goto skipRcPre +@REM check for pre script, once with legacy .bat ending and once with .cmd ending +if exist "%HOME%\mavenrc_pre.bat" call "%HOME%\mavenrc_pre.bat" +if exist "%HOME%\mavenrc_pre.cmd" call "%HOME%\mavenrc_pre.cmd" +:skipRcPre + +@setlocal + +set ERROR_CODE=0 + +@REM To isolate internal variables from possible post scripts, we use another setlocal +@setlocal + +@REM ==== START VALIDATION ==== +if not "%JAVA_HOME%" == "" goto OkJHome + +echo. +echo Error: JAVA_HOME not found in your environment. >&2 +echo Please set the JAVA_HOME variable in your environment to match the >&2 +echo location of your Java installation. >&2 +echo. +goto error + +:OkJHome +if exist "%JAVA_HOME%\bin\java.exe" goto init + +echo. +echo Error: JAVA_HOME is set to an invalid directory. >&2 +echo JAVA_HOME = "%JAVA_HOME%" >&2 +echo Please set the JAVA_HOME variable in your environment to match the >&2 +echo location of your Java installation. >&2 +echo. +goto error + +@REM ==== END VALIDATION ==== + +:init + +@REM Find the project base dir, i.e. the directory that contains the folder ".mvn". +@REM Fallback to current working directory if not found. + +set MAVEN_PROJECTBASEDIR=%MAVEN_BASEDIR% +IF NOT "%MAVEN_PROJECTBASEDIR%"=="" goto endDetectBaseDir + +set EXEC_DIR=%CD% +set WDIR=%EXEC_DIR% +:findBaseDir +IF EXIST "%WDIR%"\.mvn goto baseDirFound +cd .. +IF "%WDIR%"=="%CD%" goto baseDirNotFound +set WDIR=%CD% +goto findBaseDir + +:baseDirFound +set MAVEN_PROJECTBASEDIR=%WDIR% +cd "%EXEC_DIR%" +goto endDetectBaseDir + +:baseDirNotFound +set MAVEN_PROJECTBASEDIR=%EXEC_DIR% +cd "%EXEC_DIR%" + +:endDetectBaseDir + +IF NOT EXIST "%MAVEN_PROJECTBASEDIR%\.mvn\jvm.config" goto endReadAdditionalConfig + +@setlocal EnableExtensions EnableDelayedExpansion +for /F "usebackq delims=" %%a in ("%MAVEN_PROJECTBASEDIR%\.mvn\jvm.config") do set JVM_CONFIG_MAVEN_PROPS=!JVM_CONFIG_MAVEN_PROPS! %%a +@endlocal & set JVM_CONFIG_MAVEN_PROPS=%JVM_CONFIG_MAVEN_PROPS% + +:endReadAdditionalConfig + +SET MAVEN_JAVA_EXE="%JAVA_HOME%\bin\java.exe" + +set WRAPPER_JAR="%MAVEN_PROJECTBASEDIR%\.mvn\wrapper\maven-wrapper.jar" +set WRAPPER_LAUNCHER=org.apache.maven.wrapper.MavenWrapperMain + +%MAVEN_JAVA_EXE% %JVM_CONFIG_MAVEN_PROPS% %MAVEN_OPTS% %MAVEN_DEBUG_OPTS% -classpath %WRAPPER_JAR% "-Dmaven.multiModuleProjectDirectory=%MAVEN_PROJECTBASEDIR%" %WRAPPER_LAUNCHER% %MAVEN_CONFIG% %* +if ERRORLEVEL 1 goto error +goto end + +:error +set ERROR_CODE=1 + +:end +@endlocal & set ERROR_CODE=%ERROR_CODE% + +if not "%MAVEN_SKIP_RC%" == "" goto skipRcPost +@REM check for post script, once with legacy .bat ending and once with .cmd ending +if exist "%HOME%\mavenrc_post.bat" call "%HOME%\mavenrc_post.bat" +if exist "%HOME%\mavenrc_post.cmd" call "%HOME%\mavenrc_post.cmd" +:skipRcPost + +@REM pause the script if MAVEN_BATCH_PAUSE is set to 'on' +if "%MAVEN_BATCH_PAUSE%" == "on" pause + +if "%MAVEN_TERMINATE_CMD%" == "on" exit %ERROR_CODE% + +exit /B %ERROR_CODE% diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..7d9df60 --- /dev/null +++ b/pom.xml @@ -0,0 +1,147 @@ + + + 4.0.0 + + cn.wildfirechat + push + 0.0.1-SNAPSHOT + jar + + push + Demo project for Spring Boot + + + org.springframework.boot + spring-boot-starter-parent + 2.0.6.RELEASE + + + + + UTF-8 + UTF-8 + 1.8 + + + + + org.springframework.boot + spring-boot-starter + + + + org.springframework.boot + spring-boot-starter-web + + + + org.springframework.boot + spring-boot-starter-test + test + + + + com.xiaomi.push + mipush-sdk-server + 2.2.18 + system + ${project.basedir}/src/main/libs/MiPush_SDK_Server_2_2_19.jar + + + + + com.meizu.flyme + push-server-sdk + 1.2.7.20180307_release + + + + + com.google.code.gson + gson + 2.8.2 + + + + commons-io + commons-io + 2.5 + + + + com.googlecode.json-simple + json-simple + 1.1.1 + + + + org.slf4j + slf4j-api + 1.7.5 + + + + org.slf4j + slf4j-log4j12 + 1.7.5 + + + + + commons-httpclient + commons-httpclient + 3.1 + + + + uk.org.lidalia + slf4j-test + 1.0.0-jdk6 + test + + + + com.google.code.findbugs + annotations + 2.0.3 + provided + + + + org.mockito + mockito-all + 1.9.5 + jar + test + + + + + com.fasterxml.jackson.core + jackson-core + 2.9.4 + + + com.fasterxml.jackson.core + jackson-databind + 2.9.4 + + + com.fasterxml.jackson.core + jackson-annotations + 2.9.4 + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + + + diff --git a/src/main/java/cn/wildfirechat/push/PushApplication.java b/src/main/java/cn/wildfirechat/push/PushApplication.java new file mode 100644 index 0000000..efe66de --- /dev/null +++ b/src/main/java/cn/wildfirechat/push/PushApplication.java @@ -0,0 +1,11 @@ +package cn.wildfirechat.push; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class PushApplication { + public static void main(String[] args) { + SpringApplication.run(PushApplication.class, args); + } +} diff --git a/src/main/java/cn/wildfirechat/push/PushController.java b/src/main/java/cn/wildfirechat/push/PushController.java new file mode 100644 index 0000000..392f02d --- /dev/null +++ b/src/main/java/cn/wildfirechat/push/PushController.java @@ -0,0 +1,28 @@ +package cn.wildfirechat.push; + +import cn.wildfirechat.push.android.AndroidPushService; +import cn.wildfirechat.push.ios.IOSPushService; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RestController; + +@RestController +public class PushController { + + @Autowired + private AndroidPushService mAndroidPushService; + + @Autowired + private IOSPushService mIOSPushService; + + @PostMapping(value = "/android/push", produces = "application/json;charset=UTF-8" ) + public Object androidPush(@RequestBody PushMessage pushMessage) { + return mAndroidPushService.push(pushMessage); + } + + @PostMapping(value = "/ios/push", produces = "application/json;charset=UTF-8" ) + public Object iOSPush(@RequestBody PushMessage pushMessage) { + return mIOSPushService.push(pushMessage); + } +} diff --git a/src/main/java/cn/wildfirechat/push/PushMessage.java b/src/main/java/cn/wildfirechat/push/PushMessage.java new file mode 100644 index 0000000..b530ca3 --- /dev/null +++ b/src/main/java/cn/wildfirechat/push/PushMessage.java @@ -0,0 +1,161 @@ +package cn.wildfirechat.push; + + +public class PushMessage { + public String sender; + public String senderName; + public int convType; + public String target; + public String targetName; + public int line; + public int cntType; + public long serverTime; + //消息的类型,普通消息通知栏;voip要透传。 + public int pushMessageType; + //推送类型,android推送分为小米/华为/魅族等。ios分别为开发和发布。 + public int pushType; + public String pushContent; + public int unReceivedMsg; + public int mentionedType; + public String packageName; + public String deviceToken; + public String voipDeviceToken; + public String language; + + + public String getSender() { + return sender; + } + + public void setSender(String sender) { + this.sender = sender; + } + + public String getSenderName() { + return senderName; + } + + public void setSenderName(String senderName) { + this.senderName = senderName; + } + + public int getConvType() { + return convType; + } + + public void setConvType(int convType) { + this.convType = convType; + } + + public String getTarget() { + return target; + } + + public void setTarget(String target) { + this.target = target; + } + + public String getTargetName() { + return targetName; + } + + public void setTargetName(String targetName) { + this.targetName = targetName; + } + + public int getLine() { + return line; + } + + public void setLine(int line) { + this.line = line; + } + + public int getCntType() { + return cntType; + } + + public void setCntType(int cntType) { + this.cntType = cntType; + } + + public long getServerTime() { + return serverTime; + } + + public void setServerTime(long serverTime) { + this.serverTime = serverTime; + } + + public int getPushMessageType() { + return pushMessageType; + } + + public void setPushMessageType(int pushMessageType) { + this.pushMessageType = pushMessageType; + } + + public int getPushType() { + return pushType; + } + + public void setPushType(int pushType) { + this.pushType = pushType; + } + + public String getPushContent() { + return pushContent; + } + + public void setPushContent(String pushContent) { + this.pushContent = pushContent; + } + + public int getUnReceivedMsg() { + return unReceivedMsg; + } + + public void setUnReceivedMsg(int unReceivedMsg) { + this.unReceivedMsg = unReceivedMsg; + } + + public int getMentionedType() { + return mentionedType; + } + + public void setMentionedType(int mentionedType) { + this.mentionedType = mentionedType; + } + + public String getPackageName() { + return packageName; + } + + public void setPackageName(String packageName) { + this.packageName = packageName; + } + + public String getDeviceToken() { + return deviceToken; + } + + public void setDeviceToken(String deviceToken) { + this.deviceToken = deviceToken; + } + + public String getVoipDeviceToken() { + return voipDeviceToken; + } + + public void setVoipDeviceToken(String voipDeviceToken) { + this.voipDeviceToken = voipDeviceToken; + } + + public String getLanguage() { + return language; + } + + public void setLanguage(String language) { + this.language = language; + } +} diff --git a/src/main/java/cn/wildfirechat/push/PushMessageType.java b/src/main/java/cn/wildfirechat/push/PushMessageType.java new file mode 100644 index 0000000..535c24c --- /dev/null +++ b/src/main/java/cn/wildfirechat/push/PushMessageType.java @@ -0,0 +1,7 @@ +package cn.wildfirechat.push; + +public interface PushMessageType { + int PUSH_MESSAGE_TYPE_NORMAL = 0; + int PUSH_MESSAGE_TYPE_VOIP_INVITE = 1; + int PUSH_MESSAGE_TYPE_VOIP_BYE = 2; +} diff --git a/src/main/java/cn/wildfirechat/push/android/AndroidPushService.java b/src/main/java/cn/wildfirechat/push/android/AndroidPushService.java new file mode 100644 index 0000000..744a58d --- /dev/null +++ b/src/main/java/cn/wildfirechat/push/android/AndroidPushService.java @@ -0,0 +1,7 @@ +package cn.wildfirechat.push.android; + +import cn.wildfirechat.push.PushMessage; + +public interface AndroidPushService { + Object push(PushMessage pushMessage); +} diff --git a/src/main/java/cn/wildfirechat/push/android/AndroidPushServiceImpl.java b/src/main/java/cn/wildfirechat/push/android/AndroidPushServiceImpl.java new file mode 100644 index 0000000..2ebd7f2 --- /dev/null +++ b/src/main/java/cn/wildfirechat/push/android/AndroidPushServiceImpl.java @@ -0,0 +1,45 @@ +package cn.wildfirechat.push.android; + +import cn.wildfirechat.push.PushMessage; +import cn.wildfirechat.push.android.hms.HMSPush; +import cn.wildfirechat.push.android.meizu.MeiZuPush; +import cn.wildfirechat.push.android.xiaomi.XiaomiPush; +import com.google.gson.Gson; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +@Service +public class AndroidPushServiceImpl implements AndroidPushService { + private static final Logger LOG = LoggerFactory.getLogger(AndroidPushServiceImpl.class); + @Autowired + private HMSPush hmsPush; + + @Autowired + private MeiZuPush meiZuPush; + + @Autowired + private XiaomiPush xiaomiPush; + + + @Override + public Object push(PushMessage pushMessage) { + LOG.info("Android push {}", new Gson().toJson(pushMessage)); + switch (pushMessage.getPushType()) { + case AndroidPushType.ANDROID_PUSH_TYPE_XIAOMI: + xiaomiPush.push(pushMessage); + break; + case AndroidPushType.ANDROID_PUSH_TYPE_HUAWEI: + hmsPush.push(pushMessage); + break; + case AndroidPushType.ANDROID_PUSH_TYPE_MEIZU: + meiZuPush.push(pushMessage); + break; + default: + LOG.info("unknown push type"); + break; + } + return "ok"; + } +} diff --git a/src/main/java/cn/wildfirechat/push/android/AndroidPushType.java b/src/main/java/cn/wildfirechat/push/android/AndroidPushType.java new file mode 100644 index 0000000..f52ec04 --- /dev/null +++ b/src/main/java/cn/wildfirechat/push/android/AndroidPushType.java @@ -0,0 +1,7 @@ +package cn.wildfirechat.push.android; + +public interface AndroidPushType { + int ANDROID_PUSH_TYPE_XIAOMI = 1; + int ANDROID_PUSH_TYPE_HUAWEI = 2; + int ANDROID_PUSH_TYPE_MEIZU = 3; +} diff --git a/src/main/java/cn/wildfirechat/push/android/hms/HMSConfig.java b/src/main/java/cn/wildfirechat/push/android/hms/HMSConfig.java new file mode 100644 index 0000000..ca61c29 --- /dev/null +++ b/src/main/java/cn/wildfirechat/push/android/hms/HMSConfig.java @@ -0,0 +1,29 @@ +package cn.wildfirechat.push.android.hms; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.PropertySource; + +@Configuration +@ConfigurationProperties(prefix="hms") +@PropertySource(value = "classpath:hms.properties") +public class HMSConfig { + private String appSecret; + private String appId; + + public String getAppSecret() { + return appSecret; + } + + public void setAppSecret(String appSecret) { + this.appSecret = appSecret; + } + + public String getAppId() { + return appId; + } + + public void setAppId(String appId) { + this.appId = appId; + } +} diff --git a/src/main/java/cn/wildfirechat/push/android/hms/HMSPush.java b/src/main/java/cn/wildfirechat/push/android/hms/HMSPush.java new file mode 100644 index 0000000..4a16e03 --- /dev/null +++ b/src/main/java/cn/wildfirechat/push/android/hms/HMSPush.java @@ -0,0 +1,136 @@ +package cn.wildfirechat.push.android.hms; + + +import cn.wildfirechat.push.PushMessage; +import com.alibaba.fastjson.JSONArray; +import com.alibaba.fastjson.JSONObject; +import com.google.gson.Gson; +import org.apache.commons.io.IOUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.HttpURLConnection; +import java.net.URL; +import java.net.URLEncoder; +import java.text.MessageFormat; +import java.util.List; + +@Component +public class HMSPush { + private static final Logger LOG = LoggerFactory.getLogger(HMSPush.class); + private static final String tokenUrl = "https://login.vmall.com/oauth2/token"; //获取认证Token的URL + private static final String apiUrl = "https://api.push.hicloud.com/pushsend.do"; //应用级消息下发API + private String accessToken;//下发通知消息的认证Token + private long tokenExpiredTime; //accessToken的过期时间 + + @Autowired + private HMSConfig mConfig; + + //获取下发通知消息的认证Token + private void refreshToken() throws IOException { + LOG.info("hms refresh token"); + String msgBody = MessageFormat.format( + "grant_type=client_credentials&client_secret={0}&client_id={1}", + URLEncoder.encode(mConfig.getAppSecret(), "UTF-8"), mConfig.getAppId()); + String response = httpPost(tokenUrl, msgBody, 5000, 5000); + JSONObject obj = JSONObject.parseObject(response); + accessToken = obj.getString("access_token"); + tokenExpiredTime = System.currentTimeMillis() + obj.getLong("expires_in") - 5*60*1000; + LOG.info("hms refresh token with result {}", response); + } + + //发送Push消息 + public void push(PushMessage pushMessage) { + if (tokenExpiredTime <= System.currentTimeMillis()) { + try { + refreshToken(); + } catch (IOException e) { + e.printStackTrace(); + } + } + /*PushManager.requestToken为客户端申请token的方法,可以调用多次以防止申请token失败*/ + /*PushToken不支持手动编写,需使用客户端的onToken方法获取*/ + JSONArray deviceTokens = new JSONArray();//目标设备Token + deviceTokens.add(pushMessage.getDeviceToken()); + + + JSONObject msg = new JSONObject(); + msg.put("type", 1);//3: 通知栏消息,异步透传消息请根据接口文档设置 + msg.put("body", new Gson().toJson(pushMessage));//通知栏消息body内容 + + JSONObject hps = new JSONObject();//华为PUSH消息总结构体 + hps.put("msg", msg); + + JSONObject payload = new JSONObject(); + payload.put("hps", hps); + + LOG.info("send push to HMS {}", payload); + + try { + String postBody = MessageFormat.format( + "access_token={0}&nsp_svc={1}&nsp_ts={2}&device_token_list={3}&payload={4}", + URLEncoder.encode(accessToken,"UTF-8"), + URLEncoder.encode("openpush.message.api.send","UTF-8"), + URLEncoder.encode(String.valueOf(System.currentTimeMillis() / 1000),"UTF-8"), + URLEncoder.encode(deviceTokens.toString(),"UTF-8"), + URLEncoder.encode(payload.toString(),"UTF-8")); + + String postUrl = apiUrl + "?nsp_ctx=" + URLEncoder.encode("{\"ver\":\"1\", \"appId\":\"" + mConfig.getAppId() + "\"}", "UTF-8"); + String response = httpPost(postUrl, postBody, 5000, 5000); + LOG.info("Push to {} response {}", pushMessage.getDeviceToken(), response); + } catch (IOException e) { + e.printStackTrace(); + LOG.info("Push to {} with exception", pushMessage.getDeviceToken(), e); + } + } + + public String httpPost(String httpUrl, String data, int connectTimeout, int readTimeout) throws IOException { + OutputStream outPut = null; + HttpURLConnection urlConnection = null; + InputStream in = null; + + try { + URL url = new URL(httpUrl); + urlConnection = (HttpURLConnection)url.openConnection(); + urlConnection.setRequestMethod("POST"); + urlConnection.setDoOutput(true); + urlConnection.setDoInput(true); + urlConnection.setRequestProperty("Content-Type", "application/x-www-form-urlencoded; charset=UTF-8"); + urlConnection.setConnectTimeout(connectTimeout); + urlConnection.setReadTimeout(readTimeout); + urlConnection.connect(); + + // POST data + outPut = urlConnection.getOutputStream(); + outPut.write(data.getBytes("UTF-8")); + outPut.flush(); + + // read response + if (urlConnection.getResponseCode() < 400) { + in = urlConnection.getInputStream(); + } else { + in = urlConnection.getErrorStream(); + } + + List lines = IOUtils.readLines(in, urlConnection.getContentEncoding()); + StringBuffer strBuf = new StringBuffer(); + for (String line : lines) { + strBuf.append(line); + } + LOG.info(strBuf.toString()); + return strBuf.toString(); + } + finally { + IOUtils.closeQuietly(outPut); + IOUtils.closeQuietly(in); + if (urlConnection != null) { + urlConnection.disconnect(); + } + } + } +} diff --git a/src/main/java/cn/wildfirechat/push/android/meizu/MeiZuConfig.java b/src/main/java/cn/wildfirechat/push/android/meizu/MeiZuConfig.java new file mode 100644 index 0000000..b494a76 --- /dev/null +++ b/src/main/java/cn/wildfirechat/push/android/meizu/MeiZuConfig.java @@ -0,0 +1,29 @@ +package cn.wildfirechat.push.android.meizu; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.PropertySource; + +@Configuration +@ConfigurationProperties(prefix="meizu") +@PropertySource(value = "classpath:meizu.properties") +public class MeiZuConfig { + private String appSecret; + private long appId; + + public String getAppSecret() { + return appSecret; + } + + public void setAppSecret(String appSecret) { + this.appSecret = appSecret; + } + + public long getAppId() { + return appId; + } + + public void setAppId(long appId) { + this.appId = appId; + } +} diff --git a/src/main/java/cn/wildfirechat/push/android/meizu/MeiZuPush.java b/src/main/java/cn/wildfirechat/push/android/meizu/MeiZuPush.java new file mode 100644 index 0000000..eb01009 --- /dev/null +++ b/src/main/java/cn/wildfirechat/push/android/meizu/MeiZuPush.java @@ -0,0 +1,68 @@ +package cn.wildfirechat.push.android.meizu; + +import cn.wildfirechat.push.PushMessage; +import com.meizu.push.sdk.server.IFlymePush; +import com.meizu.push.sdk.server.constant.ResultPack; +import com.meizu.push.sdk.server.model.push.PushResult; +import com.meizu.push.sdk.server.model.push.VarnishedMessage; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; + +import javax.annotation.PostConstruct; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +@Component +public class MeiZuPush { + private static final Logger LOG = LoggerFactory.getLogger(MeiZuPush.class); + private IFlymePush flymePush; + + @PostConstruct + public void init() { + this.flymePush = new IFlymePush(mConfig.getAppSecret()); + } + + @Autowired + private MeiZuConfig mConfig; + + public void push(PushMessage pushMessage) { + //组装透传消息 + VarnishedMessage message = new VarnishedMessage.Builder() + .appId(mConfig.getAppId()) + .title("WildfireChat") + .content(pushMessage.pushContent) + .validTime(1) + .build(); + + //目标用户 + List pushIds = new ArrayList(); + pushIds.add(pushMessage.getDeviceToken()); + + try { + // 1 调用推送服务 + ResultPack result = flymePush.pushMessage(message, pushIds); + if (result.isSucceed()) { + // 2 调用推送服务成功 (其中map为设备的具体推送结果,一般业务针对超速的code类型做处理) + PushResult pushResult = result.value(); + String msgId = pushResult.getMsgId();//推送消息ID,用于推送流程明细排查 + Map> targetResultMap = pushResult.getRespTarget();//推送结果,全部推送成功,则map为empty + LOG.info("push result:" + pushResult); + if (targetResultMap != null && !targetResultMap.isEmpty()) { + System.err.println("push fail token:" + targetResultMap); + } + } else { + // 调用推送接口服务异常 eg: appId、appKey非法、推送消息非法..... + // result.code(); //服务异常码 + // result.comment();//服务异常描述 + LOG.info(String.format("pushMessage error code:%s comment:%s", result.code(), result.comment())); + } + } catch (IOException e) { + e.printStackTrace(); + } + } + +} diff --git a/src/main/java/cn/wildfirechat/push/android/xiaomi/XiaomiConfig.java b/src/main/java/cn/wildfirechat/push/android/xiaomi/XiaomiConfig.java new file mode 100644 index 0000000..6d6e952 --- /dev/null +++ b/src/main/java/cn/wildfirechat/push/android/xiaomi/XiaomiConfig.java @@ -0,0 +1,20 @@ +package cn.wildfirechat.push.android.xiaomi; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.PropertySource; + +@Configuration +@ConfigurationProperties(prefix="xiaomi") +@PropertySource(value = "classpath:xiaomi.properties") +public class XiaomiConfig { + private String appSecret; + + public String getAppSecret() { + return appSecret; + } + + public void setAppSecret(String appSecret) { + this.appSecret = appSecret; + } +} diff --git a/src/main/java/cn/wildfirechat/push/android/xiaomi/XiaomiPush.java b/src/main/java/cn/wildfirechat/push/android/xiaomi/XiaomiPush.java new file mode 100644 index 0000000..799d52b --- /dev/null +++ b/src/main/java/cn/wildfirechat/push/android/xiaomi/XiaomiPush.java @@ -0,0 +1,71 @@ +package cn.wildfirechat.push.android.xiaomi; + + +import cn.wildfirechat.push.PushMessage; +import cn.wildfirechat.push.PushMessageType; +import com.google.gson.Gson; +import com.xiaomi.xmpush.server.Constants; +import com.xiaomi.xmpush.server.Message; +import com.xiaomi.xmpush.server.Result; +import com.xiaomi.xmpush.server.Sender; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; +import org.json.simple.parser.ParseException; + + +import java.io.IOException; + +import static com.xiaomi.xmpush.server.Message.NOTIFY_TYPE_ALL; + +@Component +public class XiaomiPush { + private static final Logger LOG = LoggerFactory.getLogger(XiaomiPush.class); + @Autowired + private XiaomiConfig mConfig; + + + public void push(PushMessage pushMessage) { + Constants.useOfficial(); + Sender sender = new Sender(mConfig.getAppSecret()); + + Message message; + if(pushMessage.pushMessageType != PushMessageType.PUSH_MESSAGE_TYPE_NORMAL) { + //voip + long timeToLive = 60 * 1000; // 1 min + message = new Message.Builder() + .payload(new Gson().toJson(pushMessage)) + .restrictedPackageName(pushMessage.getPackageName()) + .passThrough(1) //透传 + .timeToLive(timeToLive) + .enableFlowControl(false) + .build(); + } else { + long timeToLive = 600 * 1000;//10 min + message = new Message.Builder() + .payload(new Gson().toJson(pushMessage)) + .title("新消息提醒") + .description(pushMessage.pushContent) + .notifyType(NOTIFY_TYPE_ALL) + .restrictedPackageName(pushMessage.getPackageName()) + .passThrough(0) + .timeToLive(timeToLive) + .enableFlowControl(true) + .build(); + } + + Result result = null; + try { + result = sender.send(message, pushMessage.getDeviceToken(), 3); + } catch (IOException e) { + e.printStackTrace(); + } catch (ParseException e) { + e.printStackTrace(); + } + + LOG.info("Server response: MessageId: " + result.getMessageId() + + " ErrorCode: " + result.getErrorCode().toString() + + " Reason: " + result.getReason()); + } +} diff --git a/src/main/java/cn/wildfirechat/push/ios/ApnsConfig.java b/src/main/java/cn/wildfirechat/push/ios/ApnsConfig.java new file mode 100644 index 0000000..63bd9f4 --- /dev/null +++ b/src/main/java/cn/wildfirechat/push/ios/ApnsConfig.java @@ -0,0 +1,86 @@ +package cn.wildfirechat.push.ios; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.PropertySource; + +@Configuration +@ConfigurationProperties(prefix="apns") +@PropertySource(value = "classpath:apns.properties") +public class ApnsConfig { + String productCerPath; + String productCerPwd; + + String developCerPath; + String developCerPwd; + + String voipCerPath; + String voipCerPwd; + + String alert; + String voipAlert; + + public String getProductCerPath() { + return productCerPath; + } + + public void setProductCerPath(String productCerPath) { + this.productCerPath = productCerPath; + } + + public String getProductCerPwd() { + return productCerPwd; + } + + public void setProductCerPwd(String productCerPwd) { + this.productCerPwd = productCerPwd; + } + + public String getDevelopCerPath() { + return developCerPath; + } + + public void setDevelopCerPath(String developCerPath) { + this.developCerPath = developCerPath; + } + + public String getDevelopCerPwd() { + return developCerPwd; + } + + public void setDevelopCerPwd(String developCerPwd) { + this.developCerPwd = developCerPwd; + } + + public String getVoipCerPath() { + return voipCerPath; + } + + public void setVoipCerPath(String voipCerPath) { + this.voipCerPath = voipCerPath; + } + + public String getVoipCerPwd() { + return voipCerPwd; + } + + public void setVoipCerPwd(String voipCerPwd) { + this.voipCerPwd = voipCerPwd; + } + + public String getAlert() { + return alert; + } + + public void setAlert(String alert) { + this.alert = alert; + } + + public String getVoipAlert() { + return voipAlert; + } + + public void setVoipAlert(String voipAlert) { + this.voipAlert = voipAlert; + } +} diff --git a/src/main/java/cn/wildfirechat/push/ios/ApnsServer.java b/src/main/java/cn/wildfirechat/push/ios/ApnsServer.java new file mode 100644 index 0000000..790ef0c --- /dev/null +++ b/src/main/java/cn/wildfirechat/push/ios/ApnsServer.java @@ -0,0 +1,187 @@ +package cn.wildfirechat.push.ios; + +import cn.wildfirechat.push.PushMessage; +import cn.wildfirechat.push.PushMessageType; +import com.notnoop.apns.*; +import com.notnoop.exceptions.ApnsDeliveryErrorException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; + +import javax.annotation.PostConstruct; +import java.util.Date; +import java.util.Map; + +import static com.notnoop.apns.DeliveryError.INVALID_TOKEN; + +@Component +public class ApnsServer implements ApnsDelegate { + private static final Logger LOG = LoggerFactory.getLogger(ApnsServer.class); + @Override + public void messageSent(ApnsNotification message, boolean resent) { + LOG.info("APNS push sent:{}", message.getDeviceToken()); + } + + @Override + public void messageSendFailed(ApnsNotification message, Throwable e) { + LOG.info("APNS push failure:{}", e.getMessage()); + if(e instanceof ApnsDeliveryErrorException) { + ApnsDeliveryErrorException apnsDeliveryErrorException = (ApnsDeliveryErrorException)e; + LOG.info("APNS error code:{}", apnsDeliveryErrorException.getDeliveryError()); + if (apnsDeliveryErrorException.getDeliveryError() == INVALID_TOKEN) { + if (message.getDeviceId() != null) { + LOG.error("Invalide token!!!"); + } else { + LOG.error("APNS ERROR without deviceId:{}", message); + } + } + + } + } + + @Override + public void connectionClosed(DeliveryError e, int messageIdentifier) { + LOG.info("111"); + } + + @Override + public void cacheLengthExceeded(int newCacheLength) { + LOG.info("111"); + } + + @Override + public void notificationsResent(int resendCount) { + LOG.info("111"); + } + + ApnsService productSvc; + ApnsService developSvc; + ApnsService voipSvc; + + @Autowired + private ApnsConfig mConfig; + + @PostConstruct + private void init() { + if (StringUtils.isEmpty(mConfig.alert)) { + mConfig.alert = "default"; + } + + if (StringUtils.isEmpty(mConfig.voipAlert)) { + mConfig.alert = "default"; + } + + productSvc = APNS.newService() + .asBatched(3, 10) + .withAppleDestination(true) + .withCert(mConfig.productCerPath, mConfig.productCerPwd) + .withDelegate(this) + .build(); + + developSvc = APNS.newService() + .asBatched(3, 10) + .withAppleDestination(false) + .withCert(mConfig.developCerPath, mConfig.developCerPwd) + .withDelegate(this) + .build(); + + voipSvc = APNS.newService() + .withAppleDestination(true) + .withCert(mConfig.voipCerPath, mConfig.voipCerPwd) + .withDelegate(this) + .build(); + + productSvc.start(); + developSvc.start(); + voipSvc.start(); + } + + + public void pushMessage(PushMessage pushMessage) { + + ApnsService service = developSvc; + if (pushMessage.getPushType() == IOSPushType.IOS_PUSH_TYPE_DISTRIBUTION) { + if (pushMessage.pushMessageType == PushMessageType.PUSH_MESSAGE_TYPE_NORMAL || StringUtils.isEmpty(pushMessage.getVoipDeviceToken())) { + service = productSvc; + } else { + service = voipSvc; + } + } + + + if (service == null) { + LOG.error("Service not exist!!!!"); + return; + } + String sound = mConfig.alert; + + String pushContent = pushMessage.getPushContent(); + if (pushMessage.pushMessageType == PushMessageType.PUSH_MESSAGE_TYPE_VOIP_INVITE) { + pushContent = "通话邀请"; + sound = mConfig.voipAlert; + } else if(pushMessage.pushMessageType == PushMessageType.PUSH_MESSAGE_TYPE_VOIP_BYE) { + pushContent = "通话结束"; + sound = null; + } + + int badge = pushMessage.getUnReceivedMsg(); + if (badge <= 0) { + badge = 1; + } + + String title; + String body; + //todo 这里需要加上语言的处理,客户端会上报自己的语言,在DeviceInfo那个类中 +// if (pushMessage.language == "zh_CN") { +// +// } else if(pushMessage.language == "US_EN") { +// +// } + if (pushMessage.convType == 1) { + title = pushMessage.targetName; + if (StringUtils.isEmpty(title)) { + title = "群聊"; + } + + if (StringUtils.isEmpty(pushMessage.senderName)) { + body = pushContent; + } else { + body = pushMessage.senderName + ":" + pushContent; + } + + if (pushMessage.mentionedType == 1) { + if (StringUtils.isEmpty(pushMessage.senderName)) { + body = "有人在群里@了你"; + } else { + body = pushMessage.senderName + "在群里@了你"; + } + } else if(pushMessage.mentionedType == 2) { + if (StringUtils.isEmpty(pushMessage.senderName)) { + body = "有人在群里@了大家"; + } else { + body = pushMessage.senderName + "在群里@了大家"; + } + } + } else { + if (StringUtils.isEmpty(pushMessage.senderName)) { + title = "消息"; + } else { + title = pushMessage.senderName; + } + body = pushContent; + } + + final String payload = APNS.newPayload().alertBody(body).badge(badge).alertTitle(title).sound(sound).build(); + final ApnsNotification goodMsg = service.push(service == voipSvc ? pushMessage.getVoipDeviceToken() : pushMessage.getDeviceToken(), payload, null); + LOG.info("Message id: " + goodMsg.getIdentifier()); + + + //检查key到期日期 + final Map inactiveDevices = service.getInactiveDevices(); + for (final Map.Entry ent : inactiveDevices.entrySet()) { + LOG.info("Inactive " + ent.getKey() + " at date " + ent.getValue()); + } + } +} diff --git a/src/main/java/cn/wildfirechat/push/ios/IOSPushService.java b/src/main/java/cn/wildfirechat/push/ios/IOSPushService.java new file mode 100644 index 0000000..0611f68 --- /dev/null +++ b/src/main/java/cn/wildfirechat/push/ios/IOSPushService.java @@ -0,0 +1,7 @@ +package cn.wildfirechat.push.ios; + +import cn.wildfirechat.push.PushMessage; + +public interface IOSPushService { + Object push(PushMessage pushMessage); +} diff --git a/src/main/java/cn/wildfirechat/push/ios/IOSPushServiceImpl.java b/src/main/java/cn/wildfirechat/push/ios/IOSPushServiceImpl.java new file mode 100644 index 0000000..c553d13 --- /dev/null +++ b/src/main/java/cn/wildfirechat/push/ios/IOSPushServiceImpl.java @@ -0,0 +1,22 @@ +package cn.wildfirechat.push.ios; + +import cn.wildfirechat.push.PushMessage; +import com.google.gson.Gson; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +@Service +public class IOSPushServiceImpl implements IOSPushService { + private static final Logger LOG = LoggerFactory.getLogger(IOSPushServiceImpl.class); + @Autowired + public ApnsServer apnsServer; + + @Override + public Object push(PushMessage pushMessage) { + LOG.info("iOS push {}", new Gson().toJson(pushMessage)); + apnsServer.pushMessage(pushMessage); + return "OK"; + } +} diff --git a/src/main/java/cn/wildfirechat/push/ios/IOSPushType.java b/src/main/java/cn/wildfirechat/push/ios/IOSPushType.java new file mode 100644 index 0000000..6562ca9 --- /dev/null +++ b/src/main/java/cn/wildfirechat/push/ios/IOSPushType.java @@ -0,0 +1,6 @@ +package cn.wildfirechat.push.ios; + +public interface IOSPushType { + int IOS_PUSH_TYPE_DISTRIBUTION = 0; + int IOS_PUSH_TYPE_DEVELOPEMENT = 1; +} diff --git a/src/main/java/com/notnoop/apns/APNS.java b/src/main/java/com/notnoop/apns/APNS.java new file mode 100755 index 0000000..9b8f68c --- /dev/null +++ b/src/main/java/com/notnoop/apns/APNS.java @@ -0,0 +1,57 @@ +/* + * Copyright 2009, Mahmood Ali. + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are + * met: + * + * * Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * * Redistributions in binary form must reproduce the above + * copyright notice, this list of conditions and the following disclaimer + * in the documentation and/or other materials provided with the + * distribution. + * * Neither the name of Mahmood Ali. nor the names of its + * contributors may be used to endorse or promote products derived from + * this software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +package com.notnoop.apns; + +/** + * The main class to interact with the APNS Service. + * + * Provides an interface to create the {@link ApnsServiceBuilder} and + * {@code ApnsNotification} payload. + * + */ +public final class APNS { + + private APNS() { throw new AssertionError("Uninstantiable class"); } + + /** + * Returns a new Payload builder + */ + public static PayloadBuilder newPayload() { + return new PayloadBuilder(); + } + + /** + * Returns a new APNS Service for sending iPhone notifications + */ + public static ApnsServiceBuilder newService() { + return new ApnsServiceBuilder(); + } +} diff --git a/src/main/java/com/notnoop/apns/ApnsDelegate.java b/src/main/java/com/notnoop/apns/ApnsDelegate.java new file mode 100755 index 0000000..f48738c --- /dev/null +++ b/src/main/java/com/notnoop/apns/ApnsDelegate.java @@ -0,0 +1,92 @@ +/* + * Copyright 2009, Mahmood Ali. + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are + * met: + * + * * Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * * Redistributions in binary form must reproduce the above + * copyright notice, this list of conditions and the following disclaimer + * in the documentation and/or other materials provided with the + * distribution. + * * Neither the name of Mahmood Ali. nor the names of its + * contributors may be used to endorse or promote products derived from + * this software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +package com.notnoop.apns; + +/** + * A delegate that gets notified of the status of notification delivery to the + * Apple Server. + * + * The delegate doesn't get notified when the notification actually arrives at + * the phone. + */ +public interface ApnsDelegate { + + /** + * Called when message was successfully sent to the Apple servers + * + * @param message the notification that was sent + * @param resent whether the notification was resent after an error + */ + public void messageSent(ApnsNotification message, boolean resent); + + /** + * Called when the delivery of the message failed for any reason + * + * If message is null, then your notification has been rejected by Apple but + * it has been removed from the cache so it is not possible to identify + * which notification caused the error. In this case subsequent + * notifications may be lost. If this happens you should consider increasing + * your cacheLength value to prevent data loss. + * + * @param message the notification that was attempted to be sent + * @param e the cause and description of the failure + */ + public void messageSendFailed(ApnsNotification message, Throwable e); + + /** + * The connection was closed and/or an error packet was received while + * monitoring was turned on. + * + * @param e the delivery error + * @param messageIdentifier id of the message that failed + */ + public void connectionClosed(DeliveryError e, int messageIdentifier); + + /** + * The resend cache needed a bigger size (while resending messages) + * + * @param newCacheLength new size of the resend cache. + */ + public void cacheLengthExceeded(int newCacheLength); + + /** + * A number of notifications has been queued for resending due to a error-response + * packet being received. + * + * @param resendCount the number of messages being queued for resend + */ + public void notificationsResent(int resendCount); + + /** + * A no operation delegate that does nothing! + */ + public final static ApnsDelegate EMPTY = new ApnsDelegateAdapter(); +} diff --git a/src/main/java/com/notnoop/apns/ApnsDelegateAdapter.java b/src/main/java/com/notnoop/apns/ApnsDelegateAdapter.java new file mode 100755 index 0000000..d10a675 --- /dev/null +++ b/src/main/java/com/notnoop/apns/ApnsDelegateAdapter.java @@ -0,0 +1,52 @@ +/* + * Copyright 2009, Mahmood Ali. + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are + * met: + * + * * Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * * Redistributions in binary form must reproduce the above + * copyright notice, this list of conditions and the following disclaimer + * in the documentation and/or other materials provided with the + * distribution. + * * Neither the name of Mahmood Ali. nor the names of its + * contributors may be used to endorse or promote products derived from + * this software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +package com.notnoop.apns; + +/** + * A no operation delegate that does nothing! + */ +public class ApnsDelegateAdapter implements ApnsDelegate { + + public void messageSent(ApnsNotification message, boolean resent) { + } + + public void messageSendFailed(ApnsNotification message, Throwable e) { + } + + public void connectionClosed(DeliveryError e, int messageIdentifier) { + } + + public void cacheLengthExceeded(int newCacheLength) { + } + + public void notificationsResent(int resendCount) { + } +} diff --git a/src/main/java/com/notnoop/apns/ApnsNotification.java b/src/main/java/com/notnoop/apns/ApnsNotification.java new file mode 100755 index 0000000..4cdeae3 --- /dev/null +++ b/src/main/java/com/notnoop/apns/ApnsNotification.java @@ -0,0 +1,76 @@ +/* + * Copyright 2009, Mahmood Ali. + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are + * met: + * + * * Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * * Redistributions in binary form must reproduce the above + * copyright notice, this list of conditions and the following disclaimer + * in the documentation and/or other materials provided with the + * distribution. + * * Neither the name of Mahmood Ali. nor the names of its + * contributors may be used to endorse or promote products derived from + * this software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +package com.notnoop.apns; + +/** + * Represents an APNS notification to be sent to Apple service. + */ +public interface ApnsNotification { + + /** + * Returns the binary representation of the device token. + */ + public byte[] getDeviceToken(); + + /** + * Returns the binary representation of the payload. + * + */ + public byte[] getPayload(); + + /** + * Returns the identifier of the current message. The + * identifier is an application generated identifier. + * + * @return the notification identifier + */ + public int getIdentifier(); + + /** + * Returns the expiry date of the notification, a fixed UNIX + * epoch date expressed in seconds + * + * @return the expiry date of the notification + */ + public int getExpiry(); + + /** + * Returns the binary representation of the message as expected by the + * APNS server. + * + * The returned array can be used to sent directly to the APNS server + * (on the wire/socket) without any modification. + */ + public byte[] marshall(); + + + public String getDeviceId(); +} diff --git a/src/main/java/com/notnoop/apns/ApnsService.java b/src/main/java/com/notnoop/apns/ApnsService.java new file mode 100755 index 0000000..11bc4ef --- /dev/null +++ b/src/main/java/com/notnoop/apns/ApnsService.java @@ -0,0 +1,140 @@ +/* + * Copyright 2009, Mahmood Ali. + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are + * met: + * + * * Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * * Redistributions in binary form must reproduce the above + * copyright notice, this list of conditions and the following disclaimer + * in the documentation and/or other materials provided with the + * distribution. + * * Neither the name of Mahmood Ali. nor the names of its + * contributors may be used to endorse or promote products derived from + * this software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +package com.notnoop.apns; + +import java.util.Collection; +import java.util.Date; +import java.util.Map; + +import com.notnoop.exceptions.NetworkIOException; + +/** + * Represents the connection and interface to the Apple APNS servers. + * + * The service is created by {@link ApnsServiceBuilder} like: + * + *
+ *   ApnsService = APNS.newService()
+ *                  .withCert("/path/to/certificate.p12", "MyCertPassword")
+ *                  .withSandboxDestination()
+ *                  .build()
+ * 
+ */ +public interface ApnsService { + + /** + * Sends a push notification with the provided {@code payload} to the + * iPhone of {@code deviceToken}. + * + * The payload needs to be a valid JSON object, otherwise it may fail + * silently. It is recommended to use {@link PayloadBuilder} to create + * one. + * + * @param deviceToken the destination iPhone device token + * @param payload The payload message + * @throws NetworkIOException if a network error occurred while + * attempting to send the message + */ + ApnsNotification push(String deviceToken, String payload, String deviceId) throws NetworkIOException; + + EnhancedApnsNotification push(String deviceToken, String payload, Date expiry, String deviceId) throws NetworkIOException; + + /** + * Sends a push notification with the provided {@code payload} to the + * iPhone of {@code deviceToken}. + * + * The payload needs to be a valid JSON object, otherwise it may fail + * silently. It is recommended to use {@link PayloadBuilder} to create + * one. + * + * @param deviceToken the destination iPhone device token + * @param payload The payload message + * @throws NetworkIOException if a network error occurred while + * attempting to send the message + */ + ApnsNotification push(byte[] deviceToken, byte[] payload, String deviceId) throws NetworkIOException; + + EnhancedApnsNotification push(byte[] deviceToken, byte[] payload, int expiry, String deviceId) throws NetworkIOException; + + /** + * Sends the provided notification {@code message} to the desired + * destination. + * @throws NetworkIOException if a network error occurred while + * attempting to send the message + */ + void push(ApnsNotification message) throws NetworkIOException; + + /** + * Starts the service. + * + * The underlying implementation may prepare its connections or + * data structures to be able to send the messages. + * + * This method is a blocking call, even if the service represents + * a Non-blocking push service. Once the service is returned, it is ready + * to accept push requests. + * + * @throws NetworkIOException if a network error occurred while + * starting the service + */ + void start(); + + /** + * Stops the service and frees any allocated resources it created for this + * service. + * + * The underlying implementation should close all connections it created, + * and possibly stop any threads as well. + */ + void stop(); + + /** + * Returns the list of devices that reported failed-delivery + * attempts to the Apple Feedback services. + * + * The result is map, mapping the device tokens as Hex Strings + * mapped to the timestamp when APNs determined that the + * application no longer exists on the device. + * @throws NetworkIOException if a network error occurred + * while retrieving invalid device connection + */ + Map getInactiveDevices() throws NetworkIOException; + + /** + * Test that the service is setup properly and the Apple servers + * are reachable. + * + * @throws NetworkIOException if the Apple servers aren't reachable + * or the service cannot send notifications for now + */ + void testConnection() throws NetworkIOException; + +} diff --git a/src/main/java/com/notnoop/apns/ApnsServiceBuilder.java b/src/main/java/com/notnoop/apns/ApnsServiceBuilder.java new file mode 100755 index 0000000..abc01f3 --- /dev/null +++ b/src/main/java/com/notnoop/apns/ApnsServiceBuilder.java @@ -0,0 +1,760 @@ +/* + * Copyright 2009, Mahmood Ali. + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are + * met: + * + * * Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * * Redistributions in binary form must reproduce the above + * copyright notice, this list of conditions and the following disclaimer + * in the documentation and/or other materials provided with the + * distribution. + * * Neither the name of Mahmood Ali. nor the names of its + * contributors may be used to endorse or promote products derived from + * this software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +package com.notnoop.apns; + +import com.notnoop.apns.internal.ApnsConnection; +import com.notnoop.apns.internal.ApnsConnectionImpl; +import com.notnoop.apns.internal.ApnsFeedbackConnection; +import com.notnoop.apns.internal.ApnsPooledConnection; +import com.notnoop.apns.internal.ApnsServiceImpl; +import com.notnoop.apns.internal.BatchApnsService; +import com.notnoop.apns.internal.QueuedApnsService; +import com.notnoop.apns.internal.SSLContextBuilder; +import com.notnoop.apns.internal.Utilities; +import com.notnoop.exceptions.InvalidSSLConfig; +import com.notnoop.exceptions.RuntimeIOException; + +import javax.net.ssl.SSLContext; +import javax.net.ssl.SSLSocketFactory; +import java.io.FileInputStream; +import java.io.FileNotFoundException; +import java.io.InputStream; +import java.net.InetSocketAddress; +import java.net.Proxy; +import java.net.Socket; +import java.security.KeyStore; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledThreadPoolExecutor; +import java.util.concurrent.ThreadFactory; + +import static com.notnoop.apns.internal.Utilities.PRODUCTION_FEEDBACK_HOST; +import static com.notnoop.apns.internal.Utilities.PRODUCTION_FEEDBACK_PORT; +import static com.notnoop.apns.internal.Utilities.PRODUCTION_GATEWAY_HOST; +import static com.notnoop.apns.internal.Utilities.PRODUCTION_GATEWAY_PORT; +import static com.notnoop.apns.internal.Utilities.SANDBOX_FEEDBACK_HOST; +import static com.notnoop.apns.internal.Utilities.SANDBOX_FEEDBACK_PORT; +import static com.notnoop.apns.internal.Utilities.SANDBOX_GATEWAY_HOST; +import static com.notnoop.apns.internal.Utilities.SANDBOX_GATEWAY_PORT; +import static java.util.concurrent.Executors.defaultThreadFactory; + +/** + * The class is used to create instances of {@link ApnsService}. + * + * Note that this class is not synchronized. If multiple threads access a + * {@code ApnsServiceBuilder} instance concurrently, and at least on of the + * threads modifies one of the attributes structurally, it must be + * synchronized externally. + * + * Starting a new {@code ApnsService} is easy: + * + *
+ *   ApnsService = APNS.newService()
+ *    .withCert("/path/to/certificate.p12", "MyCertPassword")
+ *    .withSandboxDestination()
+ *    .build()
+ * 
+ */ +public class ApnsServiceBuilder { + private static final String KEYSTORE_TYPE = "PKCS12"; + private static final String KEY_ALGORITHM = ((java.security.Security.getProperty("ssl.KeyManagerFactory.algorithm") == null)? "sunx509" : java.security.Security.getProperty("ssl.KeyManagerFactory.algorithm")); + + private SSLContext sslContext; + + private int readTimeout; + private int connectTimeout; + + private String gatewayHost; + private int gatewayPort = -1; + + private String feedbackHost; + private int feedbackPort; + private int pooledMax = 1; + private int cacheLength = ApnsConnection.DEFAULT_CACHE_LENGTH; + private boolean autoAdjustCacheLength = true; + private ExecutorService executor; + + private ReconnectPolicy reconnectPolicy = ReconnectPolicy.Provided.EVERY_HALF_HOUR.newObject(); + private boolean isQueued; + private ThreadFactory queueThreadFactory; + + private boolean isBatched; + private int batchWaitTimeInSec; + private int batchMaxWaitTimeInSec; + private ScheduledExecutorService batchThreadPoolExecutor; + + private ApnsDelegate delegate = ApnsDelegate.EMPTY; + private Proxy proxy; + private String proxyUsername; + private String proxyPassword; + private boolean errorDetection = true; + private ThreadFactory errorDetectionThreadFactory; + + /** + * Constructs a new instance of {@code ApnsServiceBuilder} + */ + public ApnsServiceBuilder() { sslContext = null; } + + /** + * Specify the certificate used to connect to Apple APNS + * servers. This relies on the path (absolute or relative to + * working path) to the keystore (*.p12) containing the + * certificate, along with the given password. + * + * The keystore needs to be of PKCS12 and the keystore + * needs to be encrypted using the SunX509 algorithm. Both + * of these settings are the default. + * + * This library does not support password-less p12 certificates, due to a + * Oracle Java library
+ * Bug 6415637. There are three workarounds: use a password-protected + * certificate, use a different boot Java SDK implementation, or construct + * the `SSLContext` yourself! Needless to say, the password-protected + * certificate is most recommended option. + * + * @param fileName the path to the certificate + * @param password the password of the keystore + * @return this + * @throws RuntimeIOException if it {@code fileName} cannot be + * found or read + * @throws InvalidSSLConfig if fileName is invalid Keystore + * or the password is invalid + */ + public ApnsServiceBuilder withCert(String fileName, String password) + throws RuntimeIOException, InvalidSSLConfig { + FileInputStream stream = null; + try { + stream = new FileInputStream(fileName); + return withCert(stream, password); + } catch (FileNotFoundException e) { + throw new RuntimeIOException(e); + } finally { + Utilities.close(stream); + } + } + + /** + * Specify the certificate used to connect to Apple APNS + * servers. This relies on the stream of keystore (*.p12) + * containing the certificate, along with the given password. + * + * The keystore needs to be of PKCS12 and the keystore + * needs to be encrypted using the SunX509 algorithm. Both + * of these settings are the default. + * + * This library does not support password-less p12 certificates, due to a + * Oracle Java library + * Bug 6415637. There are three workarounds: use a password-protected + * certificate, use a different boot Java SDK implementation, or constract + * the `SSLContext` yourself! Needless to say, the password-protected + * certificate is most recommended option. + * + * @param stream the keystore represented as input stream + * @param password the password of the keystore + * @return this + * @throws InvalidSSLConfig if stream is invalid Keystore + * or the password is invalid + */ + public ApnsServiceBuilder withCert(InputStream stream, String password) + throws InvalidSSLConfig { + assertPasswordNotEmpty(password); + return withSSLContext(new SSLContextBuilder() + .withAlgorithm(KEY_ALGORITHM) + .withCertificateKeyStore(stream, password, KEYSTORE_TYPE) + .withDefaultTrustKeyStore() + .build()); + } + + /** + * Specify the certificate used to connect to Apple APNS + * servers. This relies on a keystore (*.p12) + * containing the certificate, along with the given password. + * + * This library does not support password-less p12 certificates, due to a + * Oracle Java library + * Bug 6415637. There are three workarounds: use a password-protected + * certificate, use a different boot Java SDK implementation, or construct + * the `SSLContext` yourself! Needless to say, the password-protected + * certificate is most recommended option. + * + * @param keyStore the keystore + * @param password the password of the keystore + * @return this + * @throws InvalidSSLConfig if stream is invalid Keystore + * or the password is invalid + */ + public ApnsServiceBuilder withCert(KeyStore keyStore, String password) + throws InvalidSSLConfig { + assertPasswordNotEmpty(password); + return withSSLContext(new SSLContextBuilder() + .withAlgorithm(KEY_ALGORITHM) + .withCertificateKeyStore(keyStore, password) + .withDefaultTrustKeyStore() + .build()); + } + + /** + * Specify the certificate store used to connect to Apple APNS + * servers. This relies on the stream of keystore (*.p12 | *.jks) + * containing the keys and certificates, along with the given + * password and alias. + * + * The keystore can be either PKCS12 or JKS and the keystore + * needs to be encrypted using the SunX509 algorithm. + * + * This library does not support password-less p12 certificates, due to a + * Oracle Java library + * Bug 6415637. There are three workarounds: use a password-protected + * certificate, use a different boot Java SDK implementation, or constract + * the `SSLContext` yourself! Needless to say, the password-protected + * certificate is most recommended option. + * + * @param stream the keystore represented as input stream + * @param password the password of the keystore + * @param alias the alias identifing the key to be used + * @return this + * @throws InvalidSSLConfig if stream is an invalid Keystore, + * the password is invalid or the alias is not found + */ + public ApnsServiceBuilder withCert(InputStream stream, String password, String alias) + throws InvalidSSLConfig { + assertPasswordNotEmpty(password); + return withSSLContext(new SSLContextBuilder() + .withAlgorithm(KEY_ALGORITHM) + .withCertificateKeyStore(stream, password, KEYSTORE_TYPE, alias) + .withDefaultTrustKeyStore() + .build()); + } + + /** + * Specify the certificate store used to connect to Apple APNS + * servers. This relies on the stream of keystore (*.p12 | *.jks) + * containing the keys and certificates, along with the given + * password and alias. + * + * The keystore can be either PKCS12 or JKS and the keystore + * needs to be encrypted using the SunX509 algorithm. + * + * This library does not support password-less p12 certificates, due to a + * Oracle Java library + * Bug 6415637. There are three workarounds: use a password-protected + * certificate, use a different boot Java SDK implementation, or constract + * the `SSLContext` yourself! Needless to say, the password-protected + * certificate is most recommended option. + * + * @param keyStore the keystore + * @param password the password of the keystore + * @param alias the alias identifing the key to be used + * @return this + * @throws InvalidSSLConfig if stream is an invalid Keystore, + * the password is invalid or the alias is not found + */ + public ApnsServiceBuilder withCert(KeyStore keyStore, String password, String alias) + throws InvalidSSLConfig { + assertPasswordNotEmpty(password); + return withSSLContext(new SSLContextBuilder() + .withAlgorithm(KEY_ALGORITHM) + .withCertificateKeyStore(keyStore, password, alias) + .withDefaultTrustKeyStore() + .build()); + } + + private void assertPasswordNotEmpty(String password) { + if (password == null || password.length() == 0) { + throw new IllegalArgumentException("Passwords must be specified." + + "Oracle Java SDK does not support passwordless p12 certificates"); + } + } + + /** + * Specify the SSLContext that should be used to initiate the + * connection to Apple Server. + * + * Most clients would use {@link #withCert(InputStream, String)} + * or {@link #withCert(String, String)} instead. But some + * clients may need to represent the Keystore in a different + * format than supported. + * + * @param sslContext Context to be used to create secure connections + * @return this + */ + public ApnsServiceBuilder withSSLContext(SSLContext sslContext) { + this.sslContext = sslContext; + return this; + } + + /** + * Specify the timeout value to be set in new setSoTimeout in created + * sockets, for both feedback and push connections, in milliseconds. + * @param readTimeout timeout value to be set in new setSoTimeout + * @return this + */ + public ApnsServiceBuilder withReadTimeout(int readTimeout) { + this.readTimeout = readTimeout; + return this; + } + + /** + * Specify the timeout value to use for connectionTimeout in created + * sockets, for both feedback and push connections, in milliseconds. + * @param connectTimeout timeout value to use for connectionTimeout + * @return this + */ + public ApnsServiceBuilder withConnectTimeout(int connectTimeout) { + this.connectTimeout = connectTimeout; + return this; + } + + /** + * Specify the gateway server for sending Apple iPhone + * notifications. + * + * Most clients should use {@link #withSandboxDestination()} + * or {@link #withProductionDestination()}. Clients may use + * this method to connect to mocking tests and such. + * + * @param host hostname the notification gateway of Apple + * @param port port of the notification gateway of Apple + * @return this + */ + public ApnsServiceBuilder withGatewayDestination(String host, int port) { + this.gatewayHost = host; + this.gatewayPort = port; + return this; + } + + /** + * Specify the Feedback for getting failed devices from + * Apple iPhone Push servers. + * + * Most clients should use {@link #withSandboxDestination()} + * or {@link #withProductionDestination()}. Clients may use + * this method to connect to mocking tests and such. + * + * @param host hostname of the feedback server of Apple + * @param port port of the feedback server of Apple + * @return this + */ + public ApnsServiceBuilder withFeedbackDestination(String host, int port) { + this.feedbackHost = host; + this.feedbackPort = port; + return this; + } + + /** + * Specify to use Apple servers as iPhone gateway and feedback servers. + * + * If the passed {@code isProduction} is true, then it connects to the + * production servers, otherwise, it connects to the sandbox servers + * + * @param isProduction determines which Apple servers should be used: + * production or sandbox + * @return this + */ + public ApnsServiceBuilder withAppleDestination(boolean isProduction) { + if (isProduction) { + return withProductionDestination(); + } else { + return withSandboxDestination(); + } + } + + /** + * Specify to use the Apple sandbox servers as iPhone gateway + * and feedback servers. + * + * This is desired when in testing and pushing notifications + * with a development provision. + * + * @return this + */ + public ApnsServiceBuilder withSandboxDestination() { + return withGatewayDestination(SANDBOX_GATEWAY_HOST, SANDBOX_GATEWAY_PORT) + .withFeedbackDestination(SANDBOX_FEEDBACK_HOST, SANDBOX_FEEDBACK_PORT); + } + + /** + * Specify to use the Apple Production servers as iPhone gateway + * and feedback servers. + * + * This is desired when sending notifications to devices with + * a production provision (whether through App Store or Ad hoc + * distribution). + * + * @return this + */ + public ApnsServiceBuilder withProductionDestination() { + return withGatewayDestination(PRODUCTION_GATEWAY_HOST, PRODUCTION_GATEWAY_PORT) + .withFeedbackDestination(PRODUCTION_FEEDBACK_HOST, PRODUCTION_FEEDBACK_PORT); + } + + /** + * Specify the reconnection policy for the socket connection. + * + * Note: This option has no effect when using non-blocking + * connections. + */ + public ApnsServiceBuilder withReconnectPolicy(ReconnectPolicy rp) { + this.reconnectPolicy = rp; + return this; + } + + /** + * Specify if the notification cache should auto adjust. + * Default is true + * + * @param autoAdjustCacheLength the notification cache should auto adjust. + * @return this + */ + public ApnsServiceBuilder withAutoAdjustCacheLength(boolean autoAdjustCacheLength) { + this.autoAdjustCacheLength = autoAdjustCacheLength; + return this; + } + + /** + * Specify the reconnection policy for the socket connection. + * + * Note: This option has no effect when using non-blocking + * connections. + */ + public ApnsServiceBuilder withReconnectPolicy(ReconnectPolicy.Provided rp) { + this.reconnectPolicy = rp.newObject(); + return this; + } + + /** + * Specify the address of the SOCKS proxy the connection should + * use. + * + *

Read the + * Java Networking and Proxies guide to understand the + * proxies complexity. + * + *

Be aware that this method only handles SOCKS proxies, not + * HTTPS proxies. Use {@link #withProxy(Proxy)} instead. + * + * @param host the hostname of the SOCKS proxy + * @param port the port of the SOCKS proxy server + * @return this + */ + public ApnsServiceBuilder withSocksProxy(String host, int port) { + Proxy proxy = new Proxy(Proxy.Type.SOCKS, + new InetSocketAddress(host, port)); + return withProxy(proxy); + } + + /** + * Specify the proxy and the authentication parameters to be used + * to establish the connections to Apple Servers. + * + *

Read the + * Java Networking and Proxies guide to understand the + * proxies complexity. + * + * @param proxy the proxy object to be used to create connections + * @param proxyUsername a String object representing the username of the proxy server + * @param proxyPassword a String object representing the password of the proxy server + * @return this + */ + public ApnsServiceBuilder withAuthProxy(Proxy proxy, String proxyUsername, String proxyPassword) { + this.proxy = proxy; + this.proxyUsername = proxyUsername; + this.proxyPassword = proxyPassword; + return this; + } + + /** + * Specify the proxy to be used to establish the connections + * to Apple Servers + * + *

Read the + * Java Networking and Proxies guide to understand the + * proxies complexity. + * + * @param proxy the proxy object to be used to create connections + * @return this + */ + public ApnsServiceBuilder withProxy(Proxy proxy) { + this.proxy = proxy; + return this; + } + + /** + * Specify the number of notifications to cache for error purposes. + * Default is 100 + * + * @param cacheLength Number of notifications to cache for error purposes + * @return this + */ + public ApnsServiceBuilder withCacheLength(int cacheLength) { + this.cacheLength = cacheLength; + return this; + } + + /** + * Specify the socket to be used as underlying socket to connect + * to the APN service. + * + * This assumes that the socket connects to a SOCKS proxy. + * + * @deprecated use {@link ApnsServiceBuilder#withProxy(Proxy)} instead + * @param proxySocket the underlying socket for connections + * @return this + */ + @Deprecated + public ApnsServiceBuilder withProxySocket(Socket proxySocket) { + return this.withProxy(new Proxy(Proxy.Type.SOCKS, + proxySocket.getRemoteSocketAddress())); + } + + /** + * Constructs a pool of connections to the notification servers. + * + * Apple servers recommend using a pooled connection up to + * 15 concurrent persistent connections to the gateways. + * + * Note: This option has no effect when using non-blocking + * connections. + */ + public ApnsServiceBuilder asPool(int maxConnections) { + return asPool(Executors.newFixedThreadPool(maxConnections), maxConnections); + } + + /** + * Constructs a pool of connections to the notification servers. + * + * Apple servers recommend using a pooled connection up to + * 15 concurrent persistent connections to the gateways. + * + * Note: This option has no effect when using non-blocking + * connections. + * + * Note: The maxConnections here is used as a hint to how many connections + * get created. + */ + public ApnsServiceBuilder asPool(ExecutorService executor, int maxConnections) { + this.pooledMax = maxConnections; + this.executor = executor; + return this; + } + + /** + * Constructs a new thread with a processing queue to process + * notification requests. + * + * @return this + */ + public ApnsServiceBuilder asQueued() { + return asQueued(Executors.defaultThreadFactory()); + } + + /** + * Constructs a new thread with a processing queue to process + * notification requests. + * + * @param threadFactory + * thread factory to use for queue processing + * @return this + */ + public ApnsServiceBuilder asQueued(ThreadFactory threadFactory) { + this.isQueued = true; + this.queueThreadFactory = threadFactory; + return this; + } + + /** + * Construct service which will process notification requests in batch. + * After each request batch will wait waitTimeInSec (set as 5sec) for more request to come + * before executing but not more than maxWaitTimeInSec (set as 10sec) + * + * Note: It is not recommended to use pooled connection + */ + public ApnsServiceBuilder asBatched() { + return asBatched(5, 10); + } + + /** + * Construct service which will process notification requests in batch. + * After each request batch will wait waitTimeInSec for more request to come + * before executing but not more than maxWaitTimeInSec + * + * Note: It is not recommended to use pooled connection + * + * @param waitTimeInSec + * time to wait for more notification request before executing + * batch + * @param maxWaitTimeInSec + * maximum wait time for batch before executing + */ + public ApnsServiceBuilder asBatched(int waitTimeInSec, int maxWaitTimeInSec) { + return asBatched(waitTimeInSec, maxWaitTimeInSec, (ThreadFactory)null); + } + + /** + * Construct service which will process notification requests in batch. + * After each request batch will wait waitTimeInSec for more request to come + * before executing but not more than maxWaitTimeInSec + * + * Each batch creates new connection and close it after finished. + * In case reconnect policy is specified it will be applied by batch processing. + * E.g.: {@link ReconnectPolicy.Provided#EVERY_HALF_HOUR} will reconnect the connection in case batch is running for more than half an hour + * + * Note: It is not recommended to use pooled connection + * + * @param waitTimeInSec + * time to wait for more notification request before executing + * batch + * @param maxWaitTimeInSec + * maximum wait time for batch before executing + * @param threadFactory + * thread factory to use for batch processing + */ + public ApnsServiceBuilder asBatched(int waitTimeInSec, int maxWaitTimeInSec, ThreadFactory threadFactory) { + return asBatched(waitTimeInSec, maxWaitTimeInSec, new ScheduledThreadPoolExecutor(1, threadFactory != null ? threadFactory : defaultThreadFactory())); + } + + /** + * Construct service which will process notification requests in batch. + * After each request batch will wait waitTimeInSec for more request to come + * before executing but not more than maxWaitTimeInSec + * + * Each batch creates new connection and close it after finished. + * In case reconnect policy is specified it will be applied by batch processing. + * E.g.: {@link ReconnectPolicy.Provided#EVERY_HALF_HOUR} will reconnect the connection in case batch is running for more than half an hour + * + * Note: It is not recommended to use pooled connection + * + * @param waitTimeInSec + * time to wait for more notification request before executing + * batch + * @param maxWaitTimeInSec + * maximum wait time for batch before executing + * @param batchThreadPoolExecutor + * executor for batched processing (may be null) + */ + public ApnsServiceBuilder asBatched(int waitTimeInSec, int maxWaitTimeInSec, ScheduledExecutorService batchThreadPoolExecutor) { + this.isBatched = true; + this.batchWaitTimeInSec = waitTimeInSec; + this.batchMaxWaitTimeInSec = maxWaitTimeInSec; + this.batchThreadPoolExecutor = batchThreadPoolExecutor; + return this; + } + + /** + * Sets the delegate of the service, that gets notified of the + * status of message delivery. + * + * Note: This option has no effect when using non-blocking + * connections. + */ + public ApnsServiceBuilder withDelegate(ApnsDelegate delegate) { + this.delegate = delegate == null ? ApnsDelegate.EMPTY : delegate; + return this; + } + + /** + * Disables the enhanced error detection, enabled by the + * enhanced push notification interface. Error detection is + * enabled by default. + * + * This setting is desired when the application shouldn't spawn + * new threads. + * + * @return this + */ + public ApnsServiceBuilder withNoErrorDetection() { + this.errorDetection = false; + return this; + } + + /** + * Provide a custom source for threads used for monitoring connections. + * + * This setting is desired when the application must obtain threads from a + * controlled environment Google App Engine. + * @param threadFactory + * thread factory to use for error detection + * @return this + */ + public ApnsServiceBuilder withErrorDetectionThreadFactory(ThreadFactory threadFactory) { + this.errorDetectionThreadFactory = threadFactory; + return this; + } + + /** + * Returns a fully initialized instance of {@link ApnsService}, + * according to the requested settings. + * + * @return a new instance of ApnsService + */ + public ApnsService build() { + checkInitialization(); + ApnsService service; + + SSLSocketFactory sslFactory = sslContext.getSocketFactory(); + ApnsFeedbackConnection feedback = new ApnsFeedbackConnection(sslFactory, feedbackHost, feedbackPort, proxy, readTimeout, connectTimeout, proxyUsername, proxyPassword); + + ApnsConnection conn = new ApnsConnectionImpl(sslFactory, gatewayHost, + gatewayPort, proxy, proxyUsername, proxyPassword, reconnectPolicy, + delegate, errorDetection, errorDetectionThreadFactory, cacheLength, + autoAdjustCacheLength, readTimeout, connectTimeout); + if (pooledMax != 1) { + conn = new ApnsPooledConnection(conn, pooledMax, executor); + } + + service = new ApnsServiceImpl(conn, feedback); + + if (isQueued) { + service = new QueuedApnsService(service, queueThreadFactory); + } + + if (isBatched) { + service = new BatchApnsService(conn, feedback, batchWaitTimeInSec, batchMaxWaitTimeInSec, batchThreadPoolExecutor); + } + + service.start(); + + return service; + } + + private void checkInitialization() { + if (sslContext == null) + throw new IllegalStateException( + "SSL Certificates and attribute are not initialized\n" + + "Use .withCert() methods."); + if (gatewayHost == null || gatewayPort == -1) + throw new IllegalStateException( + "The Destination APNS server is not stated\n" + + "Use .withDestination(), withSandboxDestination(), " + + "or withProductionDestination()."); + } +} diff --git a/src/main/java/com/notnoop/apns/DeliveryError.java b/src/main/java/com/notnoop/apns/DeliveryError.java new file mode 100755 index 0000000..4ba33f6 --- /dev/null +++ b/src/main/java/com/notnoop/apns/DeliveryError.java @@ -0,0 +1,81 @@ +/* + * Copyright 2009, Mahmood Ali. + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are + * met: + * + * * Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * * Redistributions in binary form must reproduce the above + * copyright notice, this list of conditions and the following disclaimer + * in the documentation and/or other materials provided with the + * distribution. + * * Neither the name of Mahmood Ali. nor the names of its + * contributors may be used to endorse or promote products derived from + * this software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +package com.notnoop.apns; + +/** + * Errors in delivery that may get reported by Apple APN servers + */ +public enum DeliveryError { + /** + * Connection closed without any error. + * + * This may occur if the APN service faces an invalid simple + * APNS notification while running in enhanced mode + */ + NO_ERROR(0), + PROCESSING_ERROR(1), + MISSING_DEVICE_TOKEN(2), + MISSING_TOPIC(3), + MISSING_PAYLOAD(4), + INVALID_TOKEN_SIZE(5), + INVALID_TOPIC_SIZE(6), + INVALID_PAYLOAD_SIZE(7), + INVALID_TOKEN(8), + + NONE(255), + UNKNOWN(254); + + private final byte code; + DeliveryError(int code) { + this.code = (byte)code; + } + + /** The status code as specified by Apple */ + public int code() { + return code; + } + + /** + * Returns the appropriate {@code DeliveryError} enum + * corresponding to the Apple provided status code + * + * @param code status code provided by Apple + * @return the appropriate DeliveryError + */ + public static DeliveryError ofCode(int code) { + for (DeliveryError e : DeliveryError.values()) { + if (e.code == code) + return e; + } + + return UNKNOWN; + } +} diff --git a/src/main/java/com/notnoop/apns/EnhancedApnsNotification.java b/src/main/java/com/notnoop/apns/EnhancedApnsNotification.java new file mode 100755 index 0000000..b5b2661 --- /dev/null +++ b/src/main/java/com/notnoop/apns/EnhancedApnsNotification.java @@ -0,0 +1,191 @@ +/* + * Copyright 2009, Mahmood Ali. + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are + * met: + * + * * Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * * Redistributions in binary form must reproduce the above + * copyright notice, this list of conditions and the following disclaimer + * in the documentation and/or other materials provided with the + * distribution. + * * Neither the name of Mahmood Ali. nor the names of its + * contributors may be used to endorse or promote products derived from + * this software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +package com.notnoop.apns; + +import java.util.Arrays; +import java.util.concurrent.atomic.AtomicInteger; +import com.notnoop.apns.internal.Utilities; +import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import java.io.UnsupportedEncodingException; + +/** + * Represents an APNS notification to be sent to Apple service. + */ +public class EnhancedApnsNotification implements ApnsNotification { + + private static final Logger LOGGER = LoggerFactory.getLogger(EnhancedApnsNotification.class); + private final static byte COMMAND = 1; + private static AtomicInteger nextId = new AtomicInteger(0); + private final int identifier; + private final int expiry; + private final byte[] deviceToken; + private final byte[] payload; + private String deviceId; + + public void setDeviceId(String deviceId) { + this.deviceId = deviceId; + } + + public static int INCREMENT_ID() { + return nextId.incrementAndGet(); + } + + /** + * The infinite future for the purposes of Apple expiry date + */ + public final static int MAXIMUM_EXPIRY = Integer.MAX_VALUE; + + /** + * Constructs an instance of {@code ApnsNotification}. + * + * The message encodes the payload with a {@code UTF-8} encoding. + * + * @param dtoken The Hex of the device token of the destination phone + * @param payload The payload message to be sent + */ + public EnhancedApnsNotification( + int identifier, int expiryTime, + String dtoken, String payload) { + this.identifier = identifier; + this.expiry = expiryTime; + this.deviceToken = Utilities.decodeHex(dtoken); + this.payload = Utilities.toUTF8Bytes(payload); + } + + /** + * Constructs an instance of {@code ApnsNotification}. + * + * @param dtoken The binary representation of the destination device token + * @param payload The binary representation of the payload to be sent + */ + public EnhancedApnsNotification( + int identifier, int expiryTime, + byte[] dtoken, byte[] payload) { + this.identifier = identifier; + this.expiry = expiryTime; + this.deviceToken = Utilities.copyOf(dtoken); + this.payload = Utilities.copyOf(payload); + } + + /** + * Returns the binary representation of the device token. + * + */ + public byte[] getDeviceToken() { + return Utilities.copyOf(deviceToken); + } + + /** + * Returns the binary representation of the payload. + * + */ + public byte[] getPayload() { + return Utilities.copyOf(payload); + } + + public int getIdentifier() { + return identifier; + } + + public int getExpiry() { + return expiry; + } + + private byte[] marshall; + /** + * Returns the binary representation of the message as expected by the + * APNS server. + * + * The returned array can be used to sent directly to the APNS server + * (on the wire/socket) without any modification. + */ + public byte[] marshall() { + if (marshall == null) { + marshall = Utilities.marshallEnhanced(COMMAND, identifier, + expiry, deviceToken, payload); + } + return marshall.clone(); + } + + @Override + public String getDeviceId() { + return deviceId; + } + + /** + * Returns the length of the message in bytes as it is encoded on the wire. + * + * Apple require the message to be of length 255 bytes or less. + * + * @return length of encoded message in bytes + */ + public int length() { + int length = 1 + 4 + 4 + 2 + deviceToken.length + 2 + payload.length; + final int marshalledLength = marshall().length; + assert marshalledLength == length; + return length; + } + + @Override + public int hashCode() { + return (21 + + 31 * identifier + + 31 * expiry + + 31 * Arrays.hashCode(deviceToken) + + 31 * Arrays.hashCode(payload)); + } + + @Override + public boolean equals(Object obj) { + if (!(obj instanceof EnhancedApnsNotification)) + return false; + EnhancedApnsNotification o = (EnhancedApnsNotification)obj; + return (identifier == o.identifier + && expiry == o.expiry + && Arrays.equals(this.deviceToken, o.deviceToken) + && Arrays.equals(this.payload, o.payload)); + } + + @Override + @SuppressFBWarnings("DE_MIGHT_IGNORE") + public String toString() { + String payloadString; + try { + payloadString = new String(payload, "UTF-8"); + } catch (UnsupportedEncodingException ex) { + LOGGER.debug("UTF-8 charset not found on the JRE", ex); + payloadString = "???"; + } + return "Message(Id="+identifier+"; Token="+Utilities.encodeHex(deviceToken)+"; Payload="+payloadString+")"; + } +} diff --git a/src/main/java/com/notnoop/apns/PayloadBuilder.java b/src/main/java/com/notnoop/apns/PayloadBuilder.java new file mode 100755 index 0000000..798c22a --- /dev/null +++ b/src/main/java/com/notnoop/apns/PayloadBuilder.java @@ -0,0 +1,535 @@ +/* + * Copyright 2009, Mahmood Ali. + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are + * met: + * + * * Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * * Redistributions in binary form must reproduce the above + * copyright notice, this list of conditions and the following disclaimer + * in the documentation and/or other materials provided with the + * distribution. + * * Neither the name of Mahmood Ali. nor the names of its + * contributors may be used to endorse or promote products derived from + * this software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +package com.notnoop.apns; + +import java.util.Arrays; +import java.util.Collection; +import java.util.HashMap; +import java.util.Map; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.notnoop.apns.internal.Utilities; + +/** + * Represents a builder for constructing Payload requests, as + * specified by Apple Push Notification Programming Guide. + */ +public final class PayloadBuilder { + private static final ObjectMapper mapper = new ObjectMapper(); + + private final Map root; + private final Map aps; + private final Map customAlert; + + /** + * Constructs a new instance of {@code PayloadBuilder} + */ + PayloadBuilder() { + root = new HashMap(); + aps = new HashMap(); + customAlert = new HashMap(); + } + + /** + * Sets the alert body text, the text the appears to the user, + * to the passed value + * + * @param alert the text to appear to the user + * @return this + */ + public PayloadBuilder alertBody(final String alert) { + customAlert.put("body", alert); + return this; + } + + /** + * Sets the alert title text, the text the appears to the user, + * to the passed value. + * + * Used on iOS 8.2, iWatch and also Safari + * + * @param title the text to appear to the user + * @return this + */ + public PayloadBuilder alertTitle(final String title) { + customAlert.put("title", title); + return this; + } + + /** + * The key to a title string in the Localizable.strings file for the current localization. + * + * @param key the localizable message title key + * @return this + */ + public PayloadBuilder localizedTitleKey(final String key) { + customAlert.put("title-loc-key", key); + return this; + } + + /** + * Sets the arguments for the localizable title key. + * + * @param arguments the arguments to the localized alert message + * @return this + */ + public PayloadBuilder localizedTitleArguments(final Collection arguments) { + customAlert.put("title-loc-args", arguments); + return this; + } + + /** + * Sets the arguments for the localizable title key. + * + * @param arguments the arguments to the localized alert message + * @return this + */ + public PayloadBuilder localizedTitleArguments(final String... arguments) { + return localizedTitleArguments(Arrays.asList(arguments)); + } + + /** + * Sets the alert action text + * + * @param action The label of the action button + * @return this + */ + public PayloadBuilder alertAction(final String action) { + customAlert.put("action", action); + return this; + } + + /** + * Sets the "url-args" key that are paired with the placeholders + * inside the urlFormatString value of your website.json file. + * The order of the placeholders in the URL format string determines + * the order of the values supplied by the url-args array. + * + * @param urlArgs the values to be paired with the placeholders inside + * the urlFormatString value of your website.json file. + * @return this + */ + public PayloadBuilder urlArgs(final String... urlArgs){ + aps.put("url-args", urlArgs); + return this; + } + + /** + * Sets the alert sound to be played. + * + * Passing {@code null} disables the notification sound. + * + * @param sound the file name or song name to be played + * when receiving the notification + * @return this + */ + public PayloadBuilder sound(final String sound) { + if (sound != null) { + aps.put("sound", sound); + } else { + aps.remove("sound"); + } + return this; + } + + /** + * Sets the category of the notification for iOS8 notification + * actions. See 13 minutes into "What's new in iOS Notifications" + * + * Passing {@code null} removes the category. + * + * @param category the name of the category supplied to the app + * when receiving the notification + * @return this + */ + public PayloadBuilder category(final String category) { + if (category != null) { + aps.put("category", category); + } else { + aps.remove("category"); + } + return this; + } + + /** + * Sets the notification badge to be displayed next to the + * application icon. + * + * The passed value is the value that should be displayed + * (it will be added to the previous badge number), and + * a badge of 0 clears the badge indicator. + * + * @param badge the badge number to be displayed + * @return this + */ + public PayloadBuilder badge(final int badge) { + aps.put("badge", badge); + return this; + } + + /** + * Requests clearing of the badge number next to the application + * icon. + * + * This is an alias to {@code badge(0)}. + * + * @return this + */ + public PayloadBuilder clearBadge() { + return badge(0); + } + + /** + * Sets the value of action button (the right button to be + * displayed). The default value is "View". + * + * The value can be either the simple String to be displayed or + * a localizable key, and the iPhone will show the appropriate + * localized message. + * + * A {@code null} actionKey indicates no additional button + * is displayed, just the Cancel button. + * + * @param actionKey the title of the additional button + * @return this + */ + public PayloadBuilder actionKey(final String actionKey) { + customAlert.put("action-loc-key", actionKey); + return this; + } + + /** + * Set the notification view to display an action button. + * + * This is an alias to {@code actionKey(null)} + * + * @return this + */ + public PayloadBuilder noActionButton() { + return actionKey(null); + } + + /** + * Sets the notification type to be a 'newstand' notification. + * + * A Newstand Notification targets the Newstands app so that the app + * updates the subscription info and content. + * + * @return this + */ + public PayloadBuilder forNewsstand() { + aps.put("content-available", 1); + return this; + } + + /** + * With iOS7 it is possible to have the application wake up before the user opens the app. + * + * The same key-word can also be used to send 'silent' notifications. With these 'silent' notification + * a different app delegate is being invoked, allowing the app to perform background tasks. + * + * @return this + */ + public PayloadBuilder instantDeliveryOrSilentNotification() { + aps.put("content-available", 1); + return this; + } + + /** + * Set the notification localized key for the alert body + * message. + * + * @param key the localizable message body key + * @return this + */ + public PayloadBuilder localizedKey(final String key) { + customAlert.put("loc-key", key); + return this; + } + + /** + * Sets the arguments for the alert message localizable message. + * + * The iPhone doesn't localize the arguments. + * + * @param arguments the arguments to the localized alert message + * @return this + */ + public PayloadBuilder localizedArguments(final Collection arguments) { + customAlert.put("loc-args", arguments); + return this; + } + + /** + * Sets the arguments for the alert message localizable message. + * + * The iPhone doesn't localize the arguments. + * + * @param arguments the arguments to the localized alert message + * @return this + */ + public PayloadBuilder localizedArguments(final String... arguments) { + return localizedArguments(Arrays.asList(arguments)); + } + + /** + * Sets the launch image file for the push notification + * + * @param launchImage the filename of the image file in the + * application bundle. + * @return this + */ + public PayloadBuilder launchImage(final String launchImage) { + customAlert.put("launch-image", launchImage); + return this; + } + + /** + * Sets any application-specific custom fields. The values + * are presented to the application and the iPhone doesn't + * display them automatically. + * + * This can be used to pass specific values (urls, ids, etc) to + * the application in addition to the notification message + * itself. + * + * @param key the custom field name + * @param value the custom field value + * @return this + */ + public PayloadBuilder customField(final String key, final Object value) { + root.put(key, value); + return this; + } + + public PayloadBuilder mdm(final String s) { + return customField("mdm", s); + } + + /** + * Set any application-specific custom fields. These values + * are presented to the application and the iPhone doesn't + * display them automatically. + * + * This method *adds* the custom fields in the map to the + * payload, and subsequent calls add but doesn't reset the + * custom fields. + * + * @param values the custom map + * @return this + */ + public PayloadBuilder customFields(final Map values) { + root.putAll(values); + return this; + } + + /** + * Returns the length of payload bytes once marshaled to bytes + * + * @return the length of the payload + */ + public int length() { + return copy().buildBytes().length; + } + + /** + * Returns true if the payload built so far is larger than + * the size permitted by Apple (which is 2048 bytes). + * + * @return true if the result payload is too long + */ + public boolean isTooLong() { + return length() > Utilities.MAX_PAYLOAD_LENGTH; + } + + /** + * Shrinks the alert message body so that the resulting payload + * message fits within the passed expected payload length. + * + * This method performs best-effort approach, and its behavior + * is unspecified when handling alerts where the payload + * without body is already longer than the permitted size, or + * if the break occurs within word. + * + * @param payloadLength the expected max size of the payload + * @return this + */ + public PayloadBuilder resizeAlertBody(final int payloadLength) { + return resizeAlertBody(payloadLength, ""); + } + + /** + * Shrinks the alert message body so that the resulting payload + * message fits within the passed expected payload length. + * + * This method performs best-effort approach, and its behavior + * is unspecified when handling alerts where the payload + * without body is already longer than the permitted size, or + * if the break occurs within word. + * + * @param payloadLength the expected max size of the payload + * @param postfix for the truncated body, e.g. "..." + * @return this + */ + public PayloadBuilder resizeAlertBody(final int payloadLength, final String postfix) { + int currLength = length(); + if (currLength <= payloadLength) { + return this; + } + + // now we are sure that truncation is required + String body = (String)customAlert.get("body"); + + final int acceptableSize = Utilities.toUTF8Bytes(body).length + - (currLength - payloadLength + + Utilities.toUTF8Bytes(postfix).length); + body = Utilities.truncateWhenUTF8(body, acceptableSize) + postfix; + + // set it back + customAlert.put("body", body); + + // calculate the length again + currLength = length(); + + if(currLength > payloadLength) { + // string is still too long, just remove the body as the body is + // anyway not the cause OR the postfix might be too long + customAlert.remove("body"); + } + + return this; + } + + /** + * Shrinks the alert message body so that the resulting payload + * message fits within require Apple specification (2048 bytes). + * + * This method performs best-effort approach, and its behavior + * is unspecified when handling alerts where the payload + * without body is already longer than the permitted size, or + * if the break occurs within word. + * + * @return this + */ + public PayloadBuilder shrinkBody() { + return shrinkBody(""); + } + + /** + * Shrinks the alert message body so that the resulting payload + * message fits within require Apple specification (2048 bytes). + * + * This method performs best-effort approach, and its behavior + * is unspecified when handling alerts where the payload + * without body is already longer than the permitted size, or + * if the break occurs within word. + * + * @param postfix for the truncated body, e.g. "..." + * + * @return this + */ + public PayloadBuilder shrinkBody(final String postfix) { + return resizeAlertBody(Utilities.MAX_PAYLOAD_LENGTH, postfix); + } + + /** + * Returns the JSON String representation of the payload + * according to Apple APNS specification + * + * @return the String representation as expected by Apple + */ + public String build() { + if (!root.containsKey("mdm")) { + insertCustomAlert(); + root.put("aps", aps); + } + try { + return mapper.writeValueAsString(root); + } catch (final Exception e) { + throw new RuntimeException(e); + } + } + + private void insertCustomAlert() { + switch (customAlert.size()) { + case 0: + aps.remove("alert"); + break; + case 1: + if (customAlert.containsKey("body")) { + aps.put("alert", customAlert.get("body")); + break; + } + // else follow through + //$FALL-THROUGH$ + default: + aps.put("alert", customAlert); + } + } + + /** + * Returns the bytes representation of the payload according to + * Apple APNS specification + * + * @return the bytes as expected by Apple + */ + public byte[] buildBytes() { + return Utilities.toUTF8Bytes(build()); + } + + @Override + public String toString() { + return build(); + } + + private PayloadBuilder(final Map root, + final Map aps, + final Map customAlert) { + this.root = new HashMap(root); + this.aps = new HashMap(aps); + this.customAlert = new HashMap(customAlert); + } + + /** + * Returns a copy of this builder + * + * @return a copy of this builder + */ + public PayloadBuilder copy() { + return new PayloadBuilder(root, aps, customAlert); + } + + /** + * @return a new instance of Payload Builder + */ + public static PayloadBuilder newPayload() { + return new PayloadBuilder(); + } +} diff --git a/src/main/java/com/notnoop/apns/ReconnectPolicy.java b/src/main/java/com/notnoop/apns/ReconnectPolicy.java new file mode 100755 index 0000000..bff7dd9 --- /dev/null +++ b/src/main/java/com/notnoop/apns/ReconnectPolicy.java @@ -0,0 +1,118 @@ +/* + * Copyright 2009, Mahmood Ali. + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are + * met: + * + * * Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * * Redistributions in binary form must reproduce the above + * copyright notice, this list of conditions and the following disclaimer + * in the documentation and/or other materials provided with the + * distribution. + * * Neither the name of Mahmood Ali. nor the names of its + * contributors may be used to endorse or promote products derived from + * this software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +package com.notnoop.apns; + +import com.notnoop.apns.internal.ReconnectPolicies; + +/** + * Represents the reconnection policy for the library. + * + * Each object should be used exclusively for one + * {@code ApnsService} only. + */ +public interface ReconnectPolicy { + /** + * Returns {@code true} if the library should initiate a new + * connection for sending the message. + * + * The library calls this method at every message push. + * + * @return true if the library should be reconnected + */ + public boolean shouldReconnect(); + + /** + * Callback method to be called whenever the library + * makes a new connection + */ + public void reconnected(); + + /** + * Returns a deep copy of this reconnection policy, if needed. + * + * Subclasses may return this instance if the object is immutable. + */ + public ReconnectPolicy copy(); + + /** + * Types of the library provided reconnection policies. + * + * This should capture most of the commonly used cases. + */ + public enum Provided { + /** + * Only reconnect if absolutely needed, e.g. when the connection is dropped. + *

+ * Apple recommends using a persistent connection. This improves the latency of sending push notification messages. + *

+ * The down-side is that once the connection is closed ungracefully (e.g. because Apple server drops it), the library wouldn't + * detect such failure and not warn against the messages sent after the drop before the detection. + */ + NEVER { + @Override + public ReconnectPolicy newObject() { + return new ReconnectPolicies.Never(); + } + }, + + /** + * Makes a new connection if the current connection has lasted for more than half an hour. + *

+ * This is the recommended mode. + *

+ * This is the sweat-spot in my experiments between dropped connections while minimizing latency. + */ + EVERY_HALF_HOUR { + @Override + public ReconnectPolicy newObject() { + return new ReconnectPolicies.EveryHalfHour(); + } + }, + + /** + * Makes a new connection for every message being sent. + * + * This option ensures that each message is actually + * delivered to Apple. + * + * If you send a lot of messages though, + * Apple may consider your requests to be a DoS attack. + */ + EVERY_NOTIFICATION { + @Override + public ReconnectPolicy newObject() { + return new ReconnectPolicies.Always(); + } + }; + + abstract ReconnectPolicy newObject(); + } +} diff --git a/src/main/java/com/notnoop/apns/SimpleApnsNotification.java b/src/main/java/com/notnoop/apns/SimpleApnsNotification.java new file mode 100755 index 0000000..6c2dad7 --- /dev/null +++ b/src/main/java/com/notnoop/apns/SimpleApnsNotification.java @@ -0,0 +1,172 @@ +/* + * Copyright 2009, Mahmood Ali. + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are + * met: + * + * * Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * * Redistributions in binary form must reproduce the above + * copyright notice, this list of conditions and the following disclaimer + * in the documentation and/or other materials provided with the + * distribution. + * * Neither the name of Mahmood Ali. nor the names of its + * contributors may be used to endorse or promote products derived from + * this software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +package com.notnoop.apns; + +import java.util.Arrays; + +import com.notnoop.apns.internal.Utilities; +import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import java.io.UnsupportedEncodingException; + +/** + * Represents an APNS notification to be sent to Apple service. This is for legacy use only + * and should not be used in new development. + * https://developer.apple.com/library/ios/documentation/NetworkingInternet/Conceptual/RemoteNotificationsPG/Chapters/LegacyFormat.html + * + * This SimpleApnsNotification also only has limited error handling (by the APNS closing the connection + * when a bad message was received) This prevents us from location the malformed notification. + * + * As push messages sent after a malformed notification are discarded by APNS messages will get lost + * and not be delivered with the SimpleApnsNotification. + * + * @deprecated use EnhancedApnsNotification instead. + */ +@SuppressWarnings("deprecation") +@Deprecated +public class SimpleApnsNotification implements ApnsNotification { + + private static final Logger LOGGER = LoggerFactory.getLogger(SimpleApnsNotification.class); + private final static byte COMMAND = 0; + private final byte[] deviceToken; + private final byte[] payload; + + /** + * Constructs an instance of {@code ApnsNotification}. + * + * The message encodes the payload with a {@code UTF-8} encoding. + * + * @param dtoken The Hex of the device token of the destination phone + * @param payload The payload message to be sent + */ + public SimpleApnsNotification(String dtoken, String payload) { + this.deviceToken = Utilities.decodeHex(dtoken); + this.payload = Utilities.toUTF8Bytes(payload); + } + + /** + * Constructs an instance of {@code ApnsNotification}. + * + * @param dtoken The binary representation of the destination device token + * @param payload The binary representation of the payload to be sent + */ + public SimpleApnsNotification(byte[] dtoken, byte[] payload) { + this.deviceToken = Utilities.copyOf(dtoken); + this.payload = Utilities.copyOf(payload); + } + + /** + * Returns the binary representation of the device token. + * + */ + public byte[] getDeviceToken() { + return Utilities.copyOf(deviceToken); + } + + /** + * Returns the binary representation of the payload. + * + */ + public byte[] getPayload() { + return Utilities.copyOf(payload); + } + + private byte[] marshall; + /** + * Returns the binary representation of the message as expected by the + * APNS server. + * + * The returned array can be used to sent directly to the APNS server + * (on the wire/socket) without any modification. + */ + public byte[] marshall() { + if (marshall == null) + marshall = Utilities.marshall(COMMAND, deviceToken, payload); + return marshall.clone(); + } + + /** + * Returns the length of the message in bytes as it is encoded on the wire. + * + * Apple require the message to be of length 255 bytes or less. + * + * @return length of encoded message in bytes + */ + public int length() { + int length = 1 + 2 + deviceToken.length + 2 + payload.length; + final int marshalledLength = marshall().length; + assert marshalledLength == length; + return length; + } + + @Override + public int hashCode() { + return 21 + + 31 * Arrays.hashCode(deviceToken) + + 31 * Arrays.hashCode(payload); + } + + @Override + public boolean equals(Object obj) { + if (!(obj instanceof SimpleApnsNotification)) + return false; + SimpleApnsNotification o = (SimpleApnsNotification)obj; + return Arrays.equals(this.deviceToken, o.deviceToken) + && Arrays.equals(this.payload, o.payload); + } + + public int getIdentifier() { + return -1; + } + + public int getExpiry() { + return -1; + } + + @Override + @SuppressFBWarnings("DE_MIGHT_IGNORE") + public String toString() { + String payloadString; + try { + payloadString = new String(payload, "UTF-8"); + } catch (UnsupportedEncodingException ex) { + LOGGER.debug("UTF-8 charset not found on the JRE", ex); + payloadString = "???"; + } + return "Message(Token="+Utilities.encodeHex(deviceToken)+"; Payload="+payloadString+")"; + } + + @Override + public String getDeviceId() { + return null; + } +} diff --git a/src/main/java/com/notnoop/apns/StartSendingApnsDelegate.java b/src/main/java/com/notnoop/apns/StartSendingApnsDelegate.java new file mode 100755 index 0000000..cf7d957 --- /dev/null +++ b/src/main/java/com/notnoop/apns/StartSendingApnsDelegate.java @@ -0,0 +1,47 @@ +/* + * Copyright 2009, Mahmood Ali. + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are + * met: + * + * * Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * * Redistributions in binary form must reproduce the above + * copyright notice, this list of conditions and the following disclaimer + * in the documentation and/or other materials provided with the + * distribution. + * * Neither the name of Mahmood Ali. nor the names of its + * contributors may be used to endorse or promote products derived from + * this software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +package com.notnoop.apns; + +/** + * A delegate that also gets notified just before a notification is being delivered to the + * Apple Server. + */ +public interface StartSendingApnsDelegate extends ApnsDelegate { + + /** + * Called when message is about to be sent to the Apple servers. + * + * @param message the notification that is about to be sent + * @param resent whether the notification is being resent after an error + */ + public void startSending(ApnsNotification message, boolean resent); + +} diff --git a/src/main/java/com/notnoop/apns/internal/AbstractApnsService.java b/src/main/java/com/notnoop/apns/internal/AbstractApnsService.java new file mode 100755 index 0000000..5a721e9 --- /dev/null +++ b/src/main/java/com/notnoop/apns/internal/AbstractApnsService.java @@ -0,0 +1,90 @@ +/* + * Copyright 2009, Mahmood Ali. + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are + * met: + * + * * Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * * Redistributions in binary form must reproduce the above + * copyright notice, this list of conditions and the following disclaimer + * in the documentation and/or other materials provided with the + * distribution. + * * Neither the name of Mahmood Ali. nor the names of its + * contributors may be used to endorse or promote products derived from + * this software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +package com.notnoop.apns.internal; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Date; +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicInteger; + +import com.notnoop.apns.ApnsNotification; +import com.notnoop.apns.ApnsService; +import com.notnoop.apns.EnhancedApnsNotification; +import com.notnoop.exceptions.NetworkIOException; + +abstract class AbstractApnsService implements ApnsService { + private ApnsFeedbackConnection feedback; + private AtomicInteger c = new AtomicInteger(); + + public AbstractApnsService(ApnsFeedbackConnection feedback) { + this.feedback = feedback; + } + + public EnhancedApnsNotification push(String deviceToken, String payload, String deviceId) throws NetworkIOException { + EnhancedApnsNotification notification = + new EnhancedApnsNotification(c.incrementAndGet(), EnhancedApnsNotification.MAXIMUM_EXPIRY, deviceToken, payload); + notification.setDeviceId(deviceId); + push(notification); + return notification; + } + + public EnhancedApnsNotification push(String deviceToken, String payload, Date expiry, String deviceId) throws NetworkIOException { + EnhancedApnsNotification notification = + new EnhancedApnsNotification(c.incrementAndGet(), (int)(expiry.getTime() / 1000), deviceToken, payload); + notification.setDeviceId(deviceId); + push(notification); + return notification; + } + + public EnhancedApnsNotification push(byte[] deviceToken, byte[] payload, String deviceId) throws NetworkIOException { + EnhancedApnsNotification notification = + new EnhancedApnsNotification(c.incrementAndGet(), EnhancedApnsNotification.MAXIMUM_EXPIRY, deviceToken, payload); + notification.setDeviceId(deviceId); + push(notification); + return notification; + } + + public EnhancedApnsNotification push(byte[] deviceToken, byte[] payload, int expiry, String deviceId) throws NetworkIOException { + EnhancedApnsNotification notification = + new EnhancedApnsNotification(c.incrementAndGet(), expiry, deviceToken, payload); + notification.setDeviceId(deviceId); + push(notification); + return notification; + } + + public abstract void push(ApnsNotification message) throws NetworkIOException; + + public Map getInactiveDevices() throws NetworkIOException { + return feedback.getInactiveDevices(); + } +} diff --git a/src/main/java/com/notnoop/apns/internal/ApnsConnection.java b/src/main/java/com/notnoop/apns/internal/ApnsConnection.java new file mode 100755 index 0000000..757a81b --- /dev/null +++ b/src/main/java/com/notnoop/apns/internal/ApnsConnection.java @@ -0,0 +1,52 @@ +/* + * Copyright 2009, Mahmood Ali. + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are + * met: + * + * * Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * * Redistributions in binary form must reproduce the above + * copyright notice, this list of conditions and the following disclaimer + * in the documentation and/or other materials provided with the + * distribution. + * * Neither the name of Mahmood Ali. nor the names of its + * contributors may be used to endorse or promote products derived from + * this software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +package com.notnoop.apns.internal; + +import java.io.Closeable; + +import com.notnoop.apns.ApnsNotification; +import com.notnoop.exceptions.NetworkIOException; + +public interface ApnsConnection extends Closeable { + + //Default number of notifications to keep for error purposes + public static final int DEFAULT_CACHE_LENGTH = 100; + + void sendMessage(ApnsNotification m) throws NetworkIOException; + + void testConnection() throws NetworkIOException; + + ApnsConnection copy(); + + void setCacheLength(int cacheLength); + + int getCacheLength(); +} diff --git a/src/main/java/com/notnoop/apns/internal/ApnsConnectionImpl.java b/src/main/java/com/notnoop/apns/internal/ApnsConnectionImpl.java new file mode 100755 index 0000000..825aea3 --- /dev/null +++ b/src/main/java/com/notnoop/apns/internal/ApnsConnectionImpl.java @@ -0,0 +1,412 @@ +/* + * Copyright 2009, Mahmood Ali. + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are + * met: + * + * * Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * * Redistributions in binary form must reproduce the above + * copyright notice, this list of conditions and the following disclaimer + * in the documentation and/or other materials provided with the + * distribution. + * * Neither the name of Mahmood Ali. nor the names of its + * contributors may be used to endorse or promote products derived from + * this software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +package com.notnoop.apns.internal; + +import java.io.EOFException; +import java.io.IOException; +import java.io.InputStream; +import java.net.InetSocketAddress; +import java.net.Proxy; +import java.net.Socket; +import java.util.LinkedList; +import java.util.Queue; +import java.util.concurrent.ConcurrentLinkedQueue; +import java.util.concurrent.Executors; +import java.util.concurrent.ThreadFactory; +import java.util.concurrent.atomic.AtomicInteger; + +import javax.net.SocketFactory; +import javax.net.ssl.SSLHandshakeException; +import javax.net.ssl.SSLSocketFactory; +import com.notnoop.apns.ApnsDelegate; +import com.notnoop.apns.StartSendingApnsDelegate; +import com.notnoop.apns.ApnsNotification; +import com.notnoop.apns.DeliveryError; +import com.notnoop.apns.EnhancedApnsNotification; +import com.notnoop.apns.ReconnectPolicy; +import com.notnoop.exceptions.ApnsDeliveryErrorException; +import com.notnoop.exceptions.NetworkIOException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class ApnsConnectionImpl implements ApnsConnection { + + private static final Logger logger = LoggerFactory.getLogger(ApnsConnectionImpl.class); + + private final SocketFactory factory; + private final String host; + private final int port; + private final int readTimeout; + private final int connectTimeout; + private final Proxy proxy; + private final String proxyUsername; + private final String proxyPassword; + private final ReconnectPolicy reconnectPolicy; + private final ApnsDelegate delegate; + private int cacheLength; + private final boolean errorDetection; + private final ThreadFactory threadFactory; + private final boolean autoAdjustCacheLength; + private final ConcurrentLinkedQueue cachedNotifications, notificationsBuffer; + private Socket socket; + private final AtomicInteger threadId = new AtomicInteger(0); + + public ApnsConnectionImpl(SocketFactory factory, String host, int port) { + this(factory, host, port, new ReconnectPolicies.Never(), ApnsDelegate.EMPTY); + } + + private ApnsConnectionImpl(SocketFactory factory, String host, int port, ReconnectPolicy reconnectPolicy, ApnsDelegate delegate) { + this(factory, host, port, null, null, null, reconnectPolicy, delegate); + } + + private ApnsConnectionImpl(SocketFactory factory, String host, int port, Proxy proxy, String proxyUsername, String proxyPassword, + ReconnectPolicy reconnectPolicy, ApnsDelegate delegate) { + this(factory, host, port, proxy, proxyUsername, proxyPassword, reconnectPolicy, delegate, false, null, + ApnsConnection.DEFAULT_CACHE_LENGTH, true, 0, 0); + } + + public ApnsConnectionImpl(SocketFactory factory, String host, int port, Proxy proxy, String proxyUsername, String proxyPassword, + ReconnectPolicy reconnectPolicy, ApnsDelegate delegate, boolean errorDetection, ThreadFactory tf, int cacheLength, + boolean autoAdjustCacheLength, int readTimeout, int connectTimeout) { + this.factory = factory; + this.host = host; + this.port = port; + this.reconnectPolicy = reconnectPolicy; + this.delegate = delegate == null ? ApnsDelegate.EMPTY : delegate; + this.proxy = proxy; + this.errorDetection = errorDetection; + this.threadFactory = tf == null ? defaultThreadFactory() : tf; + this.cacheLength = cacheLength; + this.autoAdjustCacheLength = autoAdjustCacheLength; + this.readTimeout = readTimeout; + this.connectTimeout = connectTimeout; + this.proxyUsername = proxyUsername; + this.proxyPassword = proxyPassword; + cachedNotifications = new ConcurrentLinkedQueue(); + notificationsBuffer = new ConcurrentLinkedQueue(); + } + + private ThreadFactory defaultThreadFactory() { + return new ThreadFactory() { + ThreadFactory wrapped = Executors.defaultThreadFactory(); + @Override + public Thread newThread( Runnable r ) + { + Thread result = wrapped.newThread(r); + result.setName("MonitoringThread-"+threadId.incrementAndGet()); + result.setDaemon(true); + return result; + } + }; + } + + public synchronized void close() { + Utilities.close(socket); + } + + private void monitorSocket(final Socket socketToMonitor) { + logger.debug("Launching Monitoring Thread for socket {}", socketToMonitor); + + Thread t = threadFactory.newThread(new Runnable() { + final static int EXPECTED_SIZE = 6; + + @SuppressWarnings("InfiniteLoopStatement") + @Override + public void run() { + logger.debug("Started monitoring thread"); + try { + InputStream in; + try { + in = socketToMonitor.getInputStream(); + } catch (IOException ioe) { + logger.warn("The value of socket is null", ioe); + in = null; + } + + byte[] bytes = new byte[EXPECTED_SIZE]; + while (in != null && readPacket(in, bytes)) { + logger.debug("Error-response packet {}", Utilities.encodeHex(bytes)); + // Quickly close socket, so we won't ever try to send push notifications + // using the defective socket. + Utilities.close(socketToMonitor); + + int command = bytes[0] & 0xFF; + if (command != 8) { + throw new IOException("Unexpected command byte " + command); + } + int statusCode = bytes[1] & 0xFF; + DeliveryError e = DeliveryError.ofCode(statusCode); + + int id = Utilities.parseBytes(bytes[2], bytes[3], bytes[4], bytes[5]); + + logger.debug("Closed connection cause={}; id={}", e, id); + delegate.connectionClosed(e, id); + + Queue tempCache = new LinkedList(); + ApnsNotification notification = null; + boolean foundNotification = false; + + while (!cachedNotifications.isEmpty()) { + notification = cachedNotifications.poll(); + logger.debug("Candidate for removal, message id {}", notification.getIdentifier()); + + if (notification.getIdentifier() == id) { + logger.debug("Bad message found {}", notification.getIdentifier()); + foundNotification = true; + break; + } + tempCache.add(notification); + } + + if (foundNotification) { + logger.debug("delegate.messageSendFailed, message id {}", notification.getIdentifier()); + delegate.messageSendFailed(notification, new ApnsDeliveryErrorException(e)); + } else { + cachedNotifications.addAll(tempCache); + int resendSize = tempCache.size(); + logger.warn("Received error for message that wasn't in the cache..."); + if (autoAdjustCacheLength) { + cacheLength = cacheLength + (resendSize / 2); + delegate.cacheLengthExceeded(cacheLength); + } + logger.debug("delegate.messageSendFailed, unknown id"); + delegate.messageSendFailed(null, new ApnsDeliveryErrorException(e)); + } + + int resendSize = 0; + + while (!cachedNotifications.isEmpty()) { + + resendSize++; + final ApnsNotification resendNotification = cachedNotifications.poll(); + logger.debug("Queuing for resend {}", resendNotification.getIdentifier()); + notificationsBuffer.add(resendNotification); + } + logger.debug("resending {} notifications", resendSize); + delegate.notificationsResent(resendSize); + } + logger.debug("Monitoring input stream closed by EOF"); + + } catch (IOException e) { + // An exception when reading the error code is non-critical, it will cause another retry + // sending the message. Other than providing a more stable network connection to the APNS + // server we can't do much about it - so let's not spam the application's error log. + logger.info("Exception while waiting for error code", e); + delegate.connectionClosed(DeliveryError.UNKNOWN, -1); + } finally { + Utilities.close(socketToMonitor); + drainBuffer(); + } + } + + /** + * Read a packet like in.readFully(bytes) does - but do not throw an exception and return false if nothing + * could be read at all. + * @param in the input stream + * @param bytes the array to be filled with data + * @return true if a packet as been read, false if the stream was at EOF right at the beginning. + * @throws IOException When a problem occurs, especially EOFException when there's an EOF in the middle of the packet. + */ + private boolean readPacket(final InputStream in, final byte[] bytes) throws IOException { + final int len = bytes.length; + int n = 0; + while (n < len) { + try { + int count = in.read(bytes, n, len - n); + if (count < 0) { + throw new EOFException("EOF after reading "+n+" bytes of new packet."); + } + n += count; + } catch (IOException ioe) { + if (n == 0) + return false; + throw new IOException("Error after reading "+n+" bytes of packet", ioe); + } + } + return true; + } + }); + t.start(); + } + + private synchronized Socket getOrCreateSocket(boolean resend) throws NetworkIOException { + if (reconnectPolicy.shouldReconnect()) { + logger.debug("Reconnecting due to reconnectPolicy dictating it"); + Utilities.close(socket); + socket = null; + } + + if (socket == null || socket.isClosed()) { + try { + if (proxy == null) { + socket = factory.createSocket(host, port); + logger.debug("Connected new socket {}", socket); + } else if (proxy.type() == Proxy.Type.HTTP) { + TlsTunnelBuilder tunnelBuilder = new TlsTunnelBuilder(); + socket = tunnelBuilder.build((SSLSocketFactory) factory, proxy, proxyUsername, proxyPassword, host, port); + logger.debug("Connected new socket through http tunnel {}", socket); + } else { + boolean success = false; + Socket proxySocket = null; + try { + proxySocket = new Socket(proxy); + proxySocket.connect(new InetSocketAddress(host, port), connectTimeout); + socket = ((SSLSocketFactory) factory).createSocket(proxySocket, host, port, false); + success = true; + } finally { + if (!success) { + Utilities.close(proxySocket); + } + } + logger.debug("Connected new socket through socks tunnel {}", socket); + } + + socket.setSoTimeout(readTimeout); + socket.setKeepAlive(true); + + if (errorDetection) { + monitorSocket(socket); + } + + reconnectPolicy.reconnected(); + logger.debug("Made a new connection to APNS"); + } catch (IOException e) { + logger.error("Couldn't connect to APNS server", e); + // indicate to clients whether this is a resend or initial send + throw new NetworkIOException(e, resend); + } + } + return socket; + } + + int DELAY_IN_MS = 1000; + private static final int RETRIES = 3; + + public synchronized void sendMessage(ApnsNotification m) throws NetworkIOException { + sendMessage(m, false); + drainBuffer(); + } + + private synchronized void sendMessage(ApnsNotification m, boolean fromBuffer) throws NetworkIOException { + logger.debug("sendMessage {} fromBuffer: {}", m, fromBuffer); + + if (delegate instanceof StartSendingApnsDelegate) { + ((StartSendingApnsDelegate) delegate).startSending(m, fromBuffer); + } + + int attempts = 0; + while (true) { + try { + attempts++; + Socket socket = getOrCreateSocket(fromBuffer); + socket.getOutputStream().write(m.marshall()); + socket.getOutputStream().flush(); + cacheNotification(m); + + delegate.messageSent(m, fromBuffer); + + //logger.debug("Message \"{}\" sent", m); + attempts = 0; + break; + } catch (SSLHandshakeException e) { + // No use retrying this, it's dead Jim + throw new NetworkIOException(e); + } catch (IOException e) { + Utilities.close(socket); + if (attempts >= RETRIES) { + logger.error("Couldn't send message after " + RETRIES + " retries." + m, e); + delegate.messageSendFailed(m, e); + Utilities.wrapAndThrowAsRuntimeException(e); + } + // The first failure might be due to closed connection (which in turn might be caused by + // a message containing a bad token), so don't delay for the first retry. + // + // Additionally we don't want to spam the log file in this case, only after the second retry + // which uses the delay. + + if (attempts != 1) { + logger.info("Failed to send message " + m + "... trying again after delay", e); + Utilities.sleep(DELAY_IN_MS); + } + } + } + } + + private synchronized void drainBuffer() { + logger.debug("draining buffer"); + while (!notificationsBuffer.isEmpty()) { + final ApnsNotification notification = notificationsBuffer.poll(); + try { + sendMessage(notification, true); + } + catch (NetworkIOException ex) { + // at this point we are retrying the submission of messages but failing to connect to APNS, therefore + // notify the client of this + delegate.messageSendFailed(notification, ex); + } + } + } + + private void cacheNotification(ApnsNotification notification) { + cachedNotifications.add(notification); + while (cachedNotifications.size() > cacheLength) { + cachedNotifications.poll(); + logger.debug("Removing notification from cache " + notification); + } + } + + public ApnsConnectionImpl copy() { + return new ApnsConnectionImpl(factory, host, port, proxy, proxyUsername, proxyPassword, reconnectPolicy.copy(), delegate, + errorDetection, threadFactory, cacheLength, autoAdjustCacheLength, readTimeout, connectTimeout); + } + + public void testConnection() throws NetworkIOException { + ApnsConnectionImpl testConnection = null; + try { + testConnection = + new ApnsConnectionImpl(factory, host, port, proxy, proxyUsername, proxyPassword, reconnectPolicy.copy(), delegate); + final ApnsNotification notification = new EnhancedApnsNotification(0, 0, new byte[]{0}, new byte[]{0}); + testConnection.sendMessage(notification); + } finally { + if (testConnection != null) { + testConnection.close(); + } + } + } + + public void setCacheLength(int cacheLength) { + this.cacheLength = cacheLength; + } + + public int getCacheLength() { + return cacheLength; + } +} diff --git a/src/main/java/com/notnoop/apns/internal/ApnsFeedbackConnection.java b/src/main/java/com/notnoop/apns/internal/ApnsFeedbackConnection.java new file mode 100755 index 0000000..26db208 --- /dev/null +++ b/src/main/java/com/notnoop/apns/internal/ApnsFeedbackConnection.java @@ -0,0 +1,121 @@ +/* + * Copyright 2009, Mahmood Ali. + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are + * met: + * + * * Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * * Redistributions in binary form must reproduce the above + * copyright notice, this list of conditions and the following disclaimer + * in the documentation and/or other materials provided with the + * distribution. + * * Neither the name of Mahmood Ali. nor the names of its + * contributors may be used to endorse or promote products derived from + * this software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +package com.notnoop.apns.internal; + +import java.io.IOException; +import java.io.InputStream; +import java.net.InetSocketAddress; +import java.net.Proxy; +import java.net.Socket; +import java.util.Date; +import java.util.Map; +import javax.net.SocketFactory; +import javax.net.ssl.SSLSocketFactory; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import com.notnoop.exceptions.NetworkIOException; + +public class ApnsFeedbackConnection { + private static final Logger logger = LoggerFactory.getLogger(ApnsFeedbackConnection.class); + + private final SocketFactory factory; + private final String host; + private final int port; + private final Proxy proxy; + private final int readTimeout; + private final int connectTimeout; + private final String proxyUsername; + private final String proxyPassword; + + public ApnsFeedbackConnection(final SocketFactory factory, final String host, final int port) { + this(factory, host, port, null, 0, 0, null, null); + } + + public ApnsFeedbackConnection(final SocketFactory factory, final String host, final int port, + final Proxy proxy, int readTimeout, int connectTimeout, final String proxyUsername, final String proxyPassword) { + this.factory = factory; + this.host = host; + this.port = port; + this.proxy = proxy; + this.readTimeout = readTimeout; + this.connectTimeout = connectTimeout; + this.proxyUsername = proxyUsername; + this.proxyPassword = proxyPassword; + } + + int DELAY_IN_MS = 1000; + private static final int RETRIES = 3; + + public Map getInactiveDevices() throws NetworkIOException { + int attempts = 0; + while (true) { + try { + attempts++; + final Map result = getInactiveDevicesImpl(); + + attempts = 0; + return result; + } catch (final Exception e) { + logger.warn("Failed to retrieve invalid devices", e); + if (attempts >= RETRIES) { + logger.error("Couldn't get feedback connection", e); + Utilities.wrapAndThrowAsRuntimeException(e); + } + Utilities.sleep(DELAY_IN_MS); + } + } + } + + public Map getInactiveDevicesImpl() throws IOException { + Socket proxySocket = null; + Socket socket = null; + try { + if (proxy == null) { + socket = factory.createSocket(host, port); + } else if (proxy.type() == Proxy.Type.HTTP) { + TlsTunnelBuilder tunnelBuilder = new TlsTunnelBuilder(); + socket = tunnelBuilder.build((SSLSocketFactory) factory, proxy, proxyUsername, proxyPassword, host, port); + } else { + proxySocket = new Socket(proxy); + proxySocket.connect(new InetSocketAddress(host, port), connectTimeout); + socket = ((SSLSocketFactory) factory).createSocket(proxySocket, host, port, false); + } + socket.setSoTimeout(readTimeout); + socket.setKeepAlive(true); + final InputStream stream = socket.getInputStream(); + return Utilities.parseFeedbackStream(stream); + } finally { + Utilities.close(socket); + Utilities.close(proxySocket); + } + } + +} diff --git a/src/main/java/com/notnoop/apns/internal/ApnsPooledConnection.java b/src/main/java/com/notnoop/apns/internal/ApnsPooledConnection.java new file mode 100755 index 0000000..e27d170 --- /dev/null +++ b/src/main/java/com/notnoop/apns/internal/ApnsPooledConnection.java @@ -0,0 +1,121 @@ +/* + * Copyright 2009, Mahmood Ali. + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are + * met: + * + * * Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * * Redistributions in binary form must reproduce the above + * copyright notice, this list of conditions and the following disclaimer + * in the documentation and/or other materials provided with the + * distribution. + * * Neither the name of Mahmood Ali. nor the names of its + * contributors may be used to endorse or promote products derived from + * this software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +package com.notnoop.apns.internal; + +import java.util.concurrent.*; +import com.notnoop.apns.ApnsNotification; +import com.notnoop.exceptions.NetworkIOException; +import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class ApnsPooledConnection implements ApnsConnection { + private static final Logger logger = LoggerFactory.getLogger(ApnsPooledConnection.class); + + private final ApnsConnection prototype; + private final int max; + + private final ExecutorService executors; + private final ConcurrentLinkedQueue prototypes; + + public ApnsPooledConnection(ApnsConnection prototype, int max) { + this(prototype, max, Executors.newFixedThreadPool(max)); + } + + public ApnsPooledConnection(ApnsConnection prototype, int max, ExecutorService executors) { + this.prototype = prototype; + this.max = max; + + this.executors = executors; + this.prototypes = new ConcurrentLinkedQueue(); + } + + private final ThreadLocal uniquePrototype = + new ThreadLocal() { + protected ApnsConnection initialValue() { + ApnsConnection newCopy = prototype.copy(); + prototypes.add(newCopy); + return newCopy; + } + }; + + public void sendMessage(final ApnsNotification m) throws NetworkIOException { + Future future = executors.submit(new Callable() { + public Void call() throws Exception { + uniquePrototype.get().sendMessage(m); + return null; + } + }); + try { + future.get(); + } catch (InterruptedException ie) { + Thread.currentThread().interrupt(); + } catch (ExecutionException ee) { + if (ee.getCause() instanceof NetworkIOException) { + throw (NetworkIOException) ee.getCause(); + } + } + } + + public ApnsConnection copy() { + // TODO: Should copy executor properly.... What should copy do + // really?! + return new ApnsPooledConnection(prototype, max); + } + + public void close() { + executors.shutdown(); + try { + executors.awaitTermination(10, TimeUnit.SECONDS); + } catch (InterruptedException e) { + logger.warn("pool termination interrupted", e); + } + for (ApnsConnection conn : prototypes) { + Utilities.close(conn); + } + Utilities.close(prototype); + } + + public void testConnection() { + prototype.testConnection(); + } + + public synchronized void setCacheLength(int cacheLength) { + for (ApnsConnection conn : prototypes) { + conn.setCacheLength(cacheLength); + } + } + + @SuppressFBWarnings(value = "UG_SYNC_SET_UNSYNC_GET", justification = "prototypes is a MT-safe container") + public int getCacheLength() { + return prototypes.peek().getCacheLength(); + } +} diff --git a/src/main/java/com/notnoop/apns/internal/ApnsServiceImpl.java b/src/main/java/com/notnoop/apns/internal/ApnsServiceImpl.java new file mode 100755 index 0000000..387f5a8 --- /dev/null +++ b/src/main/java/com/notnoop/apns/internal/ApnsServiceImpl.java @@ -0,0 +1,59 @@ +/* + * Copyright 2009, Mahmood Ali. + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are + * met: + * + * * Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * * Redistributions in binary form must reproduce the above + * copyright notice, this list of conditions and the following disclaimer + * in the documentation and/or other materials provided with the + * distribution. + * * Neither the name of Mahmood Ali. nor the names of its + * contributors may be used to endorse or promote products derived from + * this software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +package com.notnoop.apns.internal; + +import com.notnoop.apns.ApnsNotification; +import com.notnoop.exceptions.NetworkIOException; + +public class ApnsServiceImpl extends AbstractApnsService { + private ApnsConnection connection; + + public ApnsServiceImpl(ApnsConnection connection, ApnsFeedbackConnection feedback) { + super(feedback); + this.connection = connection; + } + + @Override + public void push(ApnsNotification msg) throws NetworkIOException { + connection.sendMessage(msg); + } + + public void start() { + } + + public void stop() { + Utilities.close(connection); + } + + public void testConnection() { + connection.testConnection(); + } +} diff --git a/src/main/java/com/notnoop/apns/internal/BatchApnsService.java b/src/main/java/com/notnoop/apns/internal/BatchApnsService.java new file mode 100755 index 0000000..d7dcbef --- /dev/null +++ b/src/main/java/com/notnoop/apns/internal/BatchApnsService.java @@ -0,0 +1,143 @@ +/* + * Copyright 2009, Mahmood Ali. + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are + * met: + * + * * Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * * Redistributions in binary form must reproduce the above + * copyright notice, this list of conditions and the following disclaimer + * in the documentation and/or other materials provided with the + * distribution. + * * Neither the name of Mahmood Ali. nor the names of its + * contributors may be used to endorse or promote products derived from + * this software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +package com.notnoop.apns.internal; + +import static java.util.concurrent.Executors.defaultThreadFactory; + +import java.util.Queue; +import java.util.concurrent.ConcurrentLinkedQueue; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.ScheduledFuture; +import java.util.concurrent.ScheduledThreadPoolExecutor; +import java.util.concurrent.ThreadFactory; +import java.util.concurrent.TimeUnit; + +import com.notnoop.apns.ApnsNotification; +import com.notnoop.exceptions.NetworkIOException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class BatchApnsService extends AbstractApnsService { + + private static final Logger logger = LoggerFactory.getLogger(BatchApnsService.class); + + /** + * How many seconds to wait for more messages before batch is send. + * Each message reset the wait time + * + * @see #maxBatchWaitTimeInSec + */ + private int batchWaitTimeInSec = 5; + + /** + * How many seconds can be batch delayed before execution. + * This time is not exact amount after which the batch will run its roughly the time + */ + private int maxBatchWaitTimeInSec = 10; + + private long firstMessageArrivedTime; + + private ApnsConnection prototype; + + private Queue batch = new ConcurrentLinkedQueue(); + + private ScheduledExecutorService scheduleService; + private ScheduledFuture taskFuture; + + private Runnable batchRunner = new SendMessagesBatch(); + + public BatchApnsService(ApnsConnection prototype, ApnsFeedbackConnection feedback, int batchWaitTimeInSec, int maxBachWaitTimeInSec, ThreadFactory tf) { + this(prototype, feedback, batchWaitTimeInSec, maxBachWaitTimeInSec, + new ScheduledThreadPoolExecutor(1, + tf != null ? tf : defaultThreadFactory())); + } + + public BatchApnsService(ApnsConnection prototype, ApnsFeedbackConnection feedback, int batchWaitTimeInSec, int maxBachWaitTimeInSec, ScheduledExecutorService executor) { + super(feedback); + this.prototype = prototype; + this.batchWaitTimeInSec = batchWaitTimeInSec; + this.maxBatchWaitTimeInSec = maxBachWaitTimeInSec; + this.scheduleService = executor != null ? executor : new ScheduledThreadPoolExecutor(1, defaultThreadFactory()); + } + + public void start() { + // no code + } + + public void stop() { + Utilities.close(prototype); + if (taskFuture != null) { + taskFuture.cancel(true); + } + scheduleService.shutdownNow(); + } + + public void testConnection() throws NetworkIOException { + prototype.testConnection(); + } + + @Override + public void push(ApnsNotification message) throws NetworkIOException { + if (batch.isEmpty()) { + firstMessageArrivedTime = System.nanoTime(); + } + + long sinceFirstMessageSec = (System.nanoTime() - firstMessageArrivedTime) / 1000 / 1000 / 1000; + + if (taskFuture != null && sinceFirstMessageSec < maxBatchWaitTimeInSec) { + taskFuture.cancel(false); + } + + batch.add(message); + + if (taskFuture == null || taskFuture.isDone()) { + taskFuture = scheduleService.schedule(batchRunner, batchWaitTimeInSec, TimeUnit.SECONDS); + } + } + + class SendMessagesBatch implements Runnable { + public void run() { + ApnsConnection newConnection = prototype.copy(); + try { + ApnsNotification msg; + while ((msg = batch.poll()) != null) { + try { + newConnection.sendMessage(msg); + } catch (NetworkIOException e) { + logger.warn("Network exception sending message msg "+ msg.getIdentifier(), e); + } + } + } finally { + Utilities.close(newConnection); + } + } + } +} diff --git a/src/main/java/com/notnoop/apns/internal/QueuedApnsService.java b/src/main/java/com/notnoop/apns/internal/QueuedApnsService.java new file mode 100755 index 0000000..21cc820 --- /dev/null +++ b/src/main/java/com/notnoop/apns/internal/QueuedApnsService.java @@ -0,0 +1,126 @@ +/* + * Copyright 2009, Mahmood Ali. + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are + * met: + * + * * Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * * Redistributions in binary form must reproduce the above + * copyright notice, this list of conditions and the following disclaimer + * in the documentation and/or other materials provided with the + * distribution. + * * Neither the name of Mahmood Ali. nor the names of its + * contributors may be used to endorse or promote products derived from + * this software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +package com.notnoop.apns.internal; + +import java.util.Date; +import java.util.Map; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.Executors; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.ThreadFactory; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import com.notnoop.apns.ApnsNotification; +import com.notnoop.apns.ApnsService; +import com.notnoop.exceptions.NetworkIOException; + +public class QueuedApnsService extends AbstractApnsService { + + private static final Logger logger = LoggerFactory.getLogger(QueuedApnsService.class); + + private ApnsService service; + private BlockingQueue queue; + private AtomicBoolean started = new AtomicBoolean(false); + + public QueuedApnsService(ApnsService service) { + this(service, null); + } + + public QueuedApnsService(ApnsService service, final ThreadFactory tf) { + super(null); + this.service = service; + this.queue = new LinkedBlockingQueue(); + this.threadFactory = tf == null ? Executors.defaultThreadFactory() : tf; + this.thread = null; + } + + @Override + public void push(ApnsNotification msg) { + if (!started.get()) { + throw new IllegalStateException("service hasn't be started or was closed"); + } + queue.add(msg); + } + + private final ThreadFactory threadFactory; + private Thread thread; + private volatile boolean shouldContinue; + + public void start() { + if (started.getAndSet(true)) { + // I prefer if we throw a runtime IllegalStateException here, + // but I want to maintain semantic backward compatibility. + // So it is returning immediately here + return; + } + + service.start(); + shouldContinue = true; + thread = threadFactory.newThread(new Runnable() { + public void run() { + while (shouldContinue) { + try { + ApnsNotification msg = queue.take(); + service.push(msg); + } catch (InterruptedException e) { + // ignore + } catch (NetworkIOException e) { + // ignore: failed connect... + } catch (Exception e) { + // weird if we reached here - something wrong is happening, but we shouldn't stop the service anyway! + logger.warn("Unexpected message caught... Shouldn't be here", e); + } + } + } + }); + thread.start(); + } + + public void stop() { + started.set(false); + shouldContinue = false; + thread.interrupt(); + service.stop(); + } + + @Override + public Map getInactiveDevices() throws NetworkIOException { + return service.getInactiveDevices(); + } + + public void testConnection() throws NetworkIOException { + service.testConnection(); + } + +} diff --git a/src/main/java/com/notnoop/apns/internal/ReconnectPolicies.java b/src/main/java/com/notnoop/apns/internal/ReconnectPolicies.java new file mode 100755 index 0000000..b6a70eb --- /dev/null +++ b/src/main/java/com/notnoop/apns/internal/ReconnectPolicies.java @@ -0,0 +1,67 @@ +/* + * Copyright 2009, Mahmood Ali. + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are + * met: + * + * * Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * * Redistributions in binary form must reproduce the above + * copyright notice, this list of conditions and the following disclaimer + * in the documentation and/or other materials provided with the + * distribution. + * * Neither the name of Mahmood Ali. nor the names of its + * contributors may be used to endorse or promote products derived from + * this software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +package com.notnoop.apns.internal; + +import com.notnoop.apns.ReconnectPolicy; + +public final class ReconnectPolicies { + + public static class Never implements ReconnectPolicy { + + public boolean shouldReconnect() { return false; } + public void reconnected() { } + public Never copy() { return this; } + } + + public static class Always implements ReconnectPolicy { + public boolean shouldReconnect() { return true; } + public void reconnected() { } + public Always copy() { return this; } + } + + public static class EveryHalfHour implements ReconnectPolicy { + private static final long PERIOD = 30 * 60 * 1000; + + private long lastRunning = System.currentTimeMillis(); + + public boolean shouldReconnect() { + return System.currentTimeMillis() - lastRunning > PERIOD; + } + + public void reconnected() { + lastRunning = System.currentTimeMillis(); + } + + public EveryHalfHour copy() { + return new EveryHalfHour(); + } + } +} diff --git a/src/main/java/com/notnoop/apns/internal/SSLContextBuilder.java b/src/main/java/com/notnoop/apns/internal/SSLContextBuilder.java new file mode 100755 index 0000000..5175bfd --- /dev/null +++ b/src/main/java/com/notnoop/apns/internal/SSLContextBuilder.java @@ -0,0 +1,179 @@ +/* + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are + * met: + * + * * Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * * Redistributions in binary form must reproduce the above + * copyright notice, this list of conditions and the following disclaimer + * in the documentation and/or other materials provided with the + * distribution. + * * Neither the name of Mahmood Ali. nor the names of its + * contributors may be used to endorse or promote products derived from + * this software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +package com.notnoop.apns.internal; + +import com.notnoop.exceptions.InvalidSSLConfig; + +import javax.net.ssl.KeyManagerFactory; +import javax.net.ssl.SSLContext; +import javax.net.ssl.TrustManager; +import javax.net.ssl.TrustManagerFactory; +import java.io.IOException; +import java.io.InputStream; +import java.security.GeneralSecurityException; +import java.security.Key; +import java.security.KeyStore; +import java.security.KeyStoreException; +import java.security.NoSuchAlgorithmException; +import java.security.UnrecoverableKeyException; +import java.security.cert.Certificate; +import java.security.cert.CertificateException; + +public class SSLContextBuilder { + private String algorithm = "sunx509"; + private KeyManagerFactory keyManagerFactory; + private TrustManager[] trustManagers; + + public SSLContextBuilder withAlgorithm(String algorithm) { + this.algorithm = algorithm; + return this; + } + + public SSLContextBuilder withDefaultTrustKeyStore() throws InvalidSSLConfig { + try { + TrustManagerFactory trustManagerFactory = TrustManagerFactory.getInstance(algorithm); + trustManagerFactory.init((KeyStore)null); + trustManagers = trustManagerFactory.getTrustManagers(); + return this; + } catch (GeneralSecurityException e) { + throw new InvalidSSLConfig(e); + } + } + + public SSLContextBuilder withTrustKeyStore(InputStream keyStoreStream, String keyStorePassword, String keyStoreType) throws InvalidSSLConfig { + try { + final KeyStore ks = KeyStore.getInstance(keyStoreType); + ks.load(keyStoreStream, keyStorePassword.toCharArray()); + return withTrustKeyStore(ks, keyStorePassword); + } catch (GeneralSecurityException e) { + throw new InvalidSSLConfig(e); + } catch (IOException e) { + throw new InvalidSSLConfig(e); + } + + } + public SSLContextBuilder withTrustKeyStore(KeyStore keyStore, String keyStorePassword) throws InvalidSSLConfig { + try { + TrustManagerFactory trustManagerFactory = TrustManagerFactory.getInstance(algorithm); + trustManagerFactory.init(keyStore); + trustManagers = trustManagerFactory.getTrustManagers(); + return this; + } catch (GeneralSecurityException e) { + throw new InvalidSSLConfig(e); + } + } + + public SSLContextBuilder withTrustManager(TrustManager trustManager) { + trustManagers = new TrustManager[] { trustManager }; + return this; + } + + public SSLContextBuilder withCertificateKeyStore(InputStream keyStoreStream, String keyStorePassword, String keyStoreType) throws InvalidSSLConfig { + try { + final KeyStore ks = KeyStore.getInstance(keyStoreType); + ks.load(keyStoreStream, keyStorePassword.toCharArray()); + return withCertificateKeyStore(ks, keyStorePassword); + } catch (GeneralSecurityException e) { + throw new InvalidSSLConfig(e); + } catch (IOException e) { + throw new InvalidSSLConfig(e); + } + } + + public SSLContextBuilder withCertificateKeyStore(InputStream keyStoreStream, String keyStorePassword, String keyStoreType, String keyAlias) throws InvalidSSLConfig { + try { + final KeyStore ks = KeyStore.getInstance(keyStoreType); + ks.load(keyStoreStream, keyStorePassword.toCharArray()); + return withCertificateKeyStore(ks, keyStorePassword, keyAlias); + } catch (GeneralSecurityException e) { + throw new InvalidSSLConfig(e); + } catch (IOException e) { + throw new InvalidSSLConfig(e); + } + } + + public SSLContextBuilder withCertificateKeyStore(KeyStore keyStore, String keyStorePassword) throws InvalidSSLConfig { + try { + keyManagerFactory = KeyManagerFactory.getInstance(algorithm); + keyManagerFactory.init(keyStore, keyStorePassword.toCharArray()); + return this; + } catch (GeneralSecurityException e) { + throw new InvalidSSLConfig(e); + } + } + + public SSLContextBuilder withCertificateKeyStore(KeyStore keyStore, String keyStorePassword, String keyAlias) throws InvalidSSLConfig { + try { + if (!keyStore.containsAlias(keyAlias)) { + throw new InvalidSSLConfig("No key with alias " + keyAlias); + } + KeyStore singleKeyKeyStore = getKeyStoreWithSingleKey(keyStore, keyStorePassword, keyAlias); + return withCertificateKeyStore(singleKeyKeyStore, keyStorePassword); + } catch (GeneralSecurityException e) { + throw new InvalidSSLConfig(e); + } catch (IOException e) { + throw new InvalidSSLConfig(e); + } + } + + /* + * Workaround for keystores containing multiple keys. Java will take the first key that matches + * and this way we can still offer configuration for a keystore with multiple keys and a selection + * based on alias. Also much easier than making a subclass of a KeyManagerFactory + */ + private KeyStore getKeyStoreWithSingleKey(KeyStore keyStore, String keyStorePassword, String keyAlias) + throws KeyStoreException, IOException, NoSuchAlgorithmException, CertificateException, UnrecoverableKeyException { + KeyStore singleKeyKeyStore = KeyStore.getInstance(keyStore.getType(), keyStore.getProvider()); + final char[] password = keyStorePassword.toCharArray(); + singleKeyKeyStore.load(null, password); + Key key = keyStore.getKey(keyAlias, password); + Certificate[] chain = keyStore.getCertificateChain(keyAlias); + singleKeyKeyStore.setKeyEntry(keyAlias, key, password, chain); + return singleKeyKeyStore; + } + + public SSLContext build() throws InvalidSSLConfig { + if (keyManagerFactory == null) { + throw new InvalidSSLConfig("Missing KeyManagerFactory"); + } + + if (trustManagers == null) { + throw new InvalidSSLConfig("Missing TrustManagers"); + } + + try { + final SSLContext sslContext = SSLContext.getInstance("TLS"); + sslContext.init(keyManagerFactory.getKeyManagers(), trustManagers, null); + return sslContext; + } catch (GeneralSecurityException e) { + throw new InvalidSSLConfig(e); + } + } +} diff --git a/src/main/java/com/notnoop/apns/internal/TlsTunnelBuilder.java b/src/main/java/com/notnoop/apns/internal/TlsTunnelBuilder.java new file mode 100755 index 0000000..5e0d68c --- /dev/null +++ b/src/main/java/com/notnoop/apns/internal/TlsTunnelBuilder.java @@ -0,0 +1,147 @@ +/* + * Copyright 2009, Mahmood Ali. + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are + * met: + * + * * Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * * Redistributions in binary form must reproduce the above + * copyright notice, this list of conditions and the following disclaimer + * in the documentation and/or other materials provided with the + * distribution. + * * Neither the name of Mahmood Ali. nor the names of its + * contributors may be used to endorse or promote products derived from + * this software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +package com.notnoop.apns.internal; + +import java.io.IOException; +import java.net.InetSocketAddress; +import java.net.ProtocolException; +import java.net.Proxy; +import java.net.Socket; +import javax.net.ssl.SSLSocketFactory; +import edu.umd.cs.findbugs.annotations.SuppressFBWarnings; +import org.apache.commons.httpclient.ConnectMethod; +import org.apache.commons.httpclient.NTCredentials; +import org.apache.commons.httpclient.ProxyClient; +import org.apache.commons.httpclient.UsernamePasswordCredentials; +import org.apache.commons.httpclient.auth.AuthScope; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Establishes a TLS connection using an HTTP proxy. See RFC 2817 5.2. This class does + * not support proxies requiring a "Proxy-Authorization" header. + */ +public final class TlsTunnelBuilder { + + private static final Logger logger = LoggerFactory.getLogger(TlsTunnelBuilder.class); + + public Socket build(SSLSocketFactory factory, Proxy proxy, String proxyUsername, String proxyPassword, String host, int port) + throws IOException { + boolean success = false; + Socket proxySocket = null; + try { + logger.debug("Attempting to use proxy : " + proxy.toString()); + InetSocketAddress proxyAddress = (InetSocketAddress) proxy.address(); + proxySocket = makeTunnel(host, port, proxyUsername, proxyPassword, proxyAddress); + + // Handshake with the origin server. + if(proxySocket == null) { + throw new ProtocolException("Unable to create tunnel through proxy server."); + } + Socket socket = factory.createSocket(proxySocket, host, port, true /* auto close */); + success = true; + return socket; + } finally { + if (!success) { + Utilities.close(proxySocket); + } + } + } + + @SuppressFBWarnings(value = "VA_FORMAT_STRING_USES_NEWLINE", + justification = "use as according to RFC, not platform-linefeed") + Socket makeTunnel(String host, int port, String proxyUsername, + String proxyPassword, InetSocketAddress proxyAddress) throws IOException { + if(host == null || port < 0 || host.isEmpty() || proxyAddress == null){ + throw new ProtocolException("Incorrect parameters to build tunnel."); + } + logger.debug("Creating socket for Proxy : " + proxyAddress.getAddress() + ":" + proxyAddress.getPort()); + Socket socket; + try { + ProxyClient client = new ProxyClient(); + client.getParams().setParameter("http.useragent", "java-apns"); + client.getHostConfiguration().setHost(host, port); + String proxyHost = proxyAddress.getAddress().toString().substring(0, proxyAddress.getAddress().toString().indexOf("/")); + client.getHostConfiguration().setProxy(proxyHost, proxyAddress.getPort()); + + + ProxyClient.ConnectResponse response = client.connect(); + socket = response.getSocket(); + if (socket == null) { + ConnectMethod method = response.getConnectMethod(); + // Read the proxy's HTTP response. + if(method.getStatusLine().getStatusCode() == 407) { + // Proxy server returned 407. We will now try to connect with auth Header + if(proxyUsername != null && proxyPassword != null) { + socket = AuthenticateProxy(method, client,proxyHost, proxyAddress.getPort(), + proxyUsername, proxyPassword); + } else { + throw new ProtocolException("Socket not created: " + method.getStatusLine()); + } + } + } + + } catch (Exception e) { + throw new ProtocolException("Error occurred while creating proxy socket : " + e.toString()); + } + if (socket != null) { + logger.debug("Socket for proxy created successfully : " + socket.getRemoteSocketAddress().toString()); + } + return socket; + } + + private Socket AuthenticateProxy(ConnectMethod method, ProxyClient client, + String proxyHost, int proxyPort, + String proxyUsername, String proxyPassword) throws IOException { + if("ntlm".equalsIgnoreCase(method.getProxyAuthState().getAuthScheme().getSchemeName())) { + // If Auth scheme is NTLM, set NT credentials with blank host and domain name + client.getState().setProxyCredentials(new AuthScope(proxyHost, proxyPort), + new NTCredentials(proxyUsername, proxyPassword,"","")); + } else { + // If Auth scheme is Basic/Digest, set regular Credentials + client.getState().setProxyCredentials(new AuthScope(proxyHost, proxyPort), + new UsernamePasswordCredentials(proxyUsername, proxyPassword)); + } + + ProxyClient.ConnectResponse response = client.connect(); + Socket socket = response.getSocket(); + + if (socket == null) { + method = response.getConnectMethod(); + throw new ProtocolException("Proxy Authentication failed. Socket not created: " + + method.getStatusLine()); + } + return socket; + } + +} + diff --git a/src/main/java/com/notnoop/apns/internal/Utilities.java b/src/main/java/com/notnoop/apns/internal/Utilities.java new file mode 100755 index 0000000..1849098 --- /dev/null +++ b/src/main/java/com/notnoop/apns/internal/Utilities.java @@ -0,0 +1,296 @@ +/* + * Copyright 2009, Mahmood Ali. + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are + * met: + * + * * Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * * Redistributions in binary form must reproduce the above + * copyright notice, this list of conditions and the following disclaimer + * in the documentation and/or other materials provided with the + * distribution. + * * Neither the name of Mahmood Ali. nor the names of its + * contributors may be used to endorse or promote products derived from + * this software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +package com.notnoop.apns.internal; + +import java.io.ByteArrayOutputStream; +import java.io.Closeable; +import java.io.DataInputStream; +import java.io.DataOutputStream; +import java.io.EOFException; +import java.io.IOException; +import java.io.InputStream; +import java.io.UnsupportedEncodingException; +import java.net.Socket; +import java.security.GeneralSecurityException; +import java.security.KeyStore; +import java.util.Date; +import java.util.HashMap; +import java.util.Map; +import java.util.regex.Pattern; +import javax.net.ssl.KeyManagerFactory; +import javax.net.ssl.SSLContext; +import javax.net.ssl.TrustManagerFactory; +import com.notnoop.exceptions.InvalidSSLConfig; +import com.notnoop.exceptions.NetworkIOException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public final class Utilities { + private static Logger logger = LoggerFactory.getLogger(Utilities.class); + + public static final String SANDBOX_GATEWAY_HOST = "gateway.sandbox.push.apple.com"; + public static final int SANDBOX_GATEWAY_PORT = 2195; + + public static final String SANDBOX_FEEDBACK_HOST = "feedback.sandbox.push.apple.com"; + public static final int SANDBOX_FEEDBACK_PORT = 2196; + + public static final String PRODUCTION_GATEWAY_HOST = "gateway.push.apple.com"; + public static final int PRODUCTION_GATEWAY_PORT = 2195; + + public static final String PRODUCTION_FEEDBACK_HOST = "feedback.push.apple.com"; + public static final int PRODUCTION_FEEDBACK_PORT = 2196; + + public static final int MAX_PAYLOAD_LENGTH = 2048; + + private Utilities() { throw new AssertionError("Uninstantiable class"); } + + private static final Pattern pattern = Pattern.compile("[ -]"); + public static byte[] decodeHex(final String deviceToken) { + final String hex = pattern.matcher(deviceToken).replaceAll(""); + + final byte[] bts = new byte[hex.length() / 2]; + for (int i = 0; i < bts.length; i++) { + bts[i] = (byte) (charVal(hex.charAt(2 * i)) * 16 + charVal(hex.charAt(2 * i + 1))); + } + return bts; + } + + private static int charVal(final char a) { + if ('0' <= a && a <= '9') { + return (a - '0'); + } else if ('a' <= a && a <= 'f') { + return (a - 'a') + 10; + } else if ('A' <= a && a <= 'F') { + return (a - 'A') + 10; + } else { + throw new RuntimeException("Invalid hex character: " + a); + } + } + + private static final char base[] = {'0','1','2','3','4','5','6','7','8','9','A','B','C','D','E','F'}; + + public static String encodeHex(final byte[] bytes) { + final char[] chars = new char[bytes.length * 2]; + + for (int i = 0; i < bytes.length; ++i) { + final int b = (bytes[i]) & 0xFF; + chars[2 * i] = base[b >>> 4]; + chars[2 * i + 1] = base[b & 0xF]; + } + + return new String(chars); + } + + public static byte[] toUTF8Bytes(final String s) { + try { + return s.getBytes("UTF-8"); + } catch (final UnsupportedEncodingException e) { + throw new RuntimeException(e); + } + } + + public static byte[] marshall(final byte command, final byte[] deviceToken, final byte[] payload) { + final ByteArrayOutputStream boas = new ByteArrayOutputStream(); + final DataOutputStream dos = new DataOutputStream(boas); + + try { + dos.writeByte(command); + dos.writeShort(deviceToken.length); + dos.write(deviceToken); + dos.writeShort(payload.length); + dos.write(payload); + return boas.toByteArray(); + } catch (final IOException e) { + throw new AssertionError(); + } + } + + public static byte[] marshallEnhanced(final byte command, final int identifier, + final int expiryTime, final byte[] deviceToken, final byte[] payload) { + final ByteArrayOutputStream boas = new ByteArrayOutputStream(); + final DataOutputStream dos = new DataOutputStream(boas); + + try { + dos.writeByte(command); + dos.writeInt(identifier); + dos.writeInt(expiryTime); + dos.writeShort(deviceToken.length); + dos.write(deviceToken); + dos.writeShort(payload.length); + dos.write(payload); + return boas.toByteArray(); + } catch (final IOException e) { + throw new AssertionError(); + } + } + + public static Map parseFeedbackStreamRaw(final InputStream in) { + final Map result = new HashMap(); + + final DataInputStream data = new DataInputStream(in); + + while (true) { + try { + final int time = data.readInt(); + final int dtLength = data.readUnsignedShort(); + final byte[] deviceToken = new byte[dtLength]; + data.readFully(deviceToken); + + result.put(deviceToken, time); + } catch (final EOFException e) { + break; + } catch (final IOException e) { + throw new RuntimeException(e); + } + } + + return result; + } + + public static Map parseFeedbackStream(final InputStream in) { + final Map result = new HashMap(); + + final Map raw = parseFeedbackStreamRaw(in); + for (final Map.Entry entry : raw.entrySet()) { + final byte[] dtArray = entry.getKey(); + final int time = entry.getValue(); // in seconds + + final Date date = new Date(time * 1000L); // in ms + final String dtString = encodeHex(dtArray); + result.put(dtString, date); + } + + return result; + } + + public static void close(final Closeable closeable) { + logger.debug("close {}", closeable); + + try { + if (closeable != null) { + closeable.close(); + } + } catch (final IOException e) { + logger.debug("error while closing resource", e); + } + } + + public static void close(final Socket closeable) { + logger.debug("close {}", closeable); + + try { + if (closeable != null) { + closeable.close(); + } + } catch (final IOException e) { + logger.debug("error while closing socket", e); + } + } + + public static void sleep(final int delay) { + try { + Thread.sleep(delay); + } catch (final InterruptedException e1) { + Thread.currentThread().interrupt(); + } + } + + public static byte[] copyOf(final byte[] bytes) { + final byte[] copy = new byte[bytes.length]; + System.arraycopy(bytes, 0, copy, 0, bytes.length); + return copy; + } + + public static byte[] copyOfRange(final byte[] original, final int from, final int to) { + final int newLength = to - from; + if (newLength < 0) { + throw new IllegalArgumentException(from + " > " + to); + } + final byte[] copy = new byte[newLength]; + System.arraycopy(original, from, copy, 0, + Math.min(original.length - from, newLength)); + return copy; + } + + public static void wrapAndThrowAsRuntimeException(final Exception e) throws NetworkIOException { + if (e instanceof IOException) { + throw new NetworkIOException((IOException)e); + } else if (e instanceof NetworkIOException) { + throw (NetworkIOException)e; + } else if (e instanceof RuntimeException) { + throw (RuntimeException)e; + } else { + throw new RuntimeException(e); + } + } + + @SuppressWarnings({"PointlessArithmeticExpression", "PointlessBitwiseExpression"}) + public static int parseBytes(final int b1, final int b2, final int b3, final int b4) { + return ((b1 << 3 * 8) & 0xFF000000) + | ((b2 << 2 * 8) & 0x00FF0000) + | ((b3 << 1 * 8) & 0x0000FF00) + | ((b4 << 0 * 8) & 0x000000FF); + } + + // @see http://stackoverflow.com/questions/119328/how-do-i-truncate-a-java-string-to-fit-in-a-given-number-of-bytes-once-utf-8-enc + public static String truncateWhenUTF8(final String s, final int maxBytes) { + int b = 0; + for (int i = 0; i < s.length(); i++) { + final char c = s.charAt(i); + + // ranges from http://en.wikipedia.org/wiki/UTF-8 + int skip = 0; + int more; + if (c <= 0x007f) { + more = 1; + } + else if (c <= 0x07FF) { + more = 2; + } else if (c <= 0xd7ff) { + more = 3; + } else if (c <= 0xDFFF) { + // surrogate area, consume next char as well + more = 4; + skip = 1; + } else { + more = 3; + } + + if (b + more > maxBytes) { + return s.substring(0, i); + } + b += more; + i += skip; + } + return s; + } + +} diff --git a/src/main/java/com/notnoop/exceptions/ApnsDeliveryErrorException.java b/src/main/java/com/notnoop/exceptions/ApnsDeliveryErrorException.java new file mode 100755 index 0000000..c9b3bea --- /dev/null +++ b/src/main/java/com/notnoop/exceptions/ApnsDeliveryErrorException.java @@ -0,0 +1,61 @@ +/* + * Copyright 2009, Mahmood Ali. + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are + * met: + * + * * Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * * Redistributions in binary form must reproduce the above + * copyright notice, this list of conditions and the following disclaimer + * in the documentation and/or other materials provided with the + * distribution. + * * Neither the name of Mahmood Ali. nor the names of its + * contributors may be used to endorse or promote products derived from + * this software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +/* + * To change this template, choose Tools | Templates + * and open the template in the editor. + */ +package com.notnoop.exceptions; + +import com.notnoop.apns.DeliveryError; + +/** + * + * @author kkirch + */ +public class ApnsDeliveryErrorException extends ApnsException { + + private final DeliveryError deliveryError; + + public ApnsDeliveryErrorException(DeliveryError error) { + this.deliveryError = error; + } + + @Override + public String getMessage() { + return "Failed to deliver notification with error code " + deliveryError.code(); + } + + public DeliveryError getDeliveryError() { + return deliveryError; + } + + +} diff --git a/src/main/java/com/notnoop/exceptions/ApnsException.java b/src/main/java/com/notnoop/exceptions/ApnsException.java new file mode 100755 index 0000000..f1725e5 --- /dev/null +++ b/src/main/java/com/notnoop/exceptions/ApnsException.java @@ -0,0 +1,44 @@ +/* + * Copyright 2009, Mahmood Ali. + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are + * met: + * + * * Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * * Redistributions in binary form must reproduce the above + * copyright notice, this list of conditions and the following disclaimer + * in the documentation and/or other materials provided with the + * distribution. + * * Neither the name of Mahmood Ali. nor the names of its + * contributors may be used to endorse or promote products derived from + * this software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +package com.notnoop.exceptions; + +/** + * Base class for all the exceptions thrown in Apns Library + */ +public abstract class ApnsException extends RuntimeException { + private static final long serialVersionUID = -4756693306121825229L; + + public ApnsException() { super(); } + public ApnsException(String message) { super(message); } + public ApnsException(Throwable cause) { super(cause); } + public ApnsException(String m, Throwable c) { super(m, c); } + +} diff --git a/src/main/java/com/notnoop/exceptions/InvalidSSLConfig.java b/src/main/java/com/notnoop/exceptions/InvalidSSLConfig.java new file mode 100755 index 0000000..be97578 --- /dev/null +++ b/src/main/java/com/notnoop/exceptions/InvalidSSLConfig.java @@ -0,0 +1,64 @@ +/* + * Copyright 2009, Mahmood Ali. + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are + * met: + * + * * Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * * Redistributions in binary form must reproduce the above + * copyright notice, this list of conditions and the following disclaimer + * in the documentation and/or other materials provided with the + * distribution. + * * Neither the name of Mahmood Ali. nor the names of its + * contributors may be used to endorse or promote products derived from + * this software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +package com.notnoop.exceptions; + +import java.io.IOException; +import java.security.KeyManagementException; +import java.security.KeyStoreException; +import java.security.NoSuchAlgorithmException; +import java.security.UnrecoverableKeyException; +import java.security.cert.CertificateException; + +/** + * Signals that the the provided SSL context settings (e.g. + * keystore path, password, encryption type, etc) are invalid + * + * This Exception can be caused by any of the following: + * + *

    + *
  1. {@link KeyStoreException}
  2. + *
  3. {@link NoSuchAlgorithmException}
  4. + *
  5. {@link CertificateException}
  6. + *
  7. {@link IOException}
  8. + *
  9. {@link UnrecoverableKeyException}
  10. + *
  11. {@link KeyManagementException}
  12. + *
+ * + */ +public class InvalidSSLConfig extends ApnsException { + private static final long serialVersionUID = -7283168775864517167L; + + public InvalidSSLConfig() { super(); } + public InvalidSSLConfig(String message) { super(message); } + public InvalidSSLConfig(Throwable cause) { super(cause); } + public InvalidSSLConfig(String m, Throwable c) { super(m, c); } + +} diff --git a/src/main/java/com/notnoop/exceptions/NetworkIOException.java b/src/main/java/com/notnoop/exceptions/NetworkIOException.java new file mode 100755 index 0000000..df84eb5 --- /dev/null +++ b/src/main/java/com/notnoop/exceptions/NetworkIOException.java @@ -0,0 +1,69 @@ +/* + * Copyright 2009, Mahmood Ali. + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are + * met: + * + * * Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * * Redistributions in binary form must reproduce the above + * copyright notice, this list of conditions and the following disclaimer + * in the documentation and/or other materials provided with the + * distribution. + * * Neither the name of Mahmood Ali. nor the names of its + * contributors may be used to endorse or promote products derived from + * this software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +package com.notnoop.exceptions; + +import java.io.IOException; + +/** + * Thrown to indicate that that a network operation has failed: + * (e.g. connectivity problems, domain cannot be found, network + * dropped). + */ +public class NetworkIOException extends ApnsException { + private static final long serialVersionUID = 3353516625486306533L; + + private boolean resend; + + public NetworkIOException() { super(); } + public NetworkIOException(String message) { super(message); } + public NetworkIOException(IOException cause) { super(cause); } + public NetworkIOException(String m, IOException c) { super(m, c); } + public NetworkIOException(IOException cause, boolean resend) { + super(cause); + this.resend = resend; + } + + /** + * Identifies whether an exception was thrown during a resend of a + * message or not. In this case a resend refers to whether the + * message is being resent from the buffer of messages internal. + * This would occur if we sent 5 messages quickly to APNS: + * 1,2,3,4,5 and the 3 message was rejected. We would + * then need to resend 4 and 5. If a network exception was + * triggered when doing this, then the resend flag will be + * {@code true}. + * @return {@code true} for an exception trigger during a resend, otherwise {@code false}. + */ + public boolean isResend() { + return resend; + } + +} diff --git a/src/main/java/com/notnoop/exceptions/RuntimeIOException.java b/src/main/java/com/notnoop/exceptions/RuntimeIOException.java new file mode 100755 index 0000000..1a447bd --- /dev/null +++ b/src/main/java/com/notnoop/exceptions/RuntimeIOException.java @@ -0,0 +1,50 @@ +/* + * Copyright 2009, Mahmood Ali. + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are + * met: + * + * * Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * * Redistributions in binary form must reproduce the above + * copyright notice, this list of conditions and the following disclaimer + * in the documentation and/or other materials provided with the + * distribution. + * * Neither the name of Mahmood Ali. nor the names of its + * contributors may be used to endorse or promote products derived from + * this software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +package com.notnoop.exceptions; + +import java.io.IOException; + +/** + * Signals that an I/O exception of some sort has occurred. This + * class is the general class of exceptions produced by failed or + * interrupted I/O operations. + * + * This is a RuntimeException, unlike the java.io.IOException + */ +public class RuntimeIOException extends ApnsException { + private static final long serialVersionUID = 8665285084049041306L; + + public RuntimeIOException() { super(); } + public RuntimeIOException(String message) { super(message); } + public RuntimeIOException(IOException cause) { super(cause); } + public RuntimeIOException(String m, IOException c) { super(m, c); } + +} diff --git a/src/main/libs/MiPush_SDK_Server_2_2_19.jar b/src/main/libs/MiPush_SDK_Server_2_2_19.jar new file mode 100644 index 0000000000000000000000000000000000000000..2aea5d1b98bb32c0e4b2643529b50555fc052dbf GIT binary patch literal 94365 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

sw36svwm31c0jF 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