diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..abae093 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,14 @@ +name: Android CI +on: [push] + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - name: set up JDK 1.8 + uses: actions/setup-java@v1 + with: + java-version: 11 + - name: Run CI + run: make ci \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c1c5557 --- /dev/null +++ b/.gitignore @@ -0,0 +1,21 @@ +*.iml +.gradle +/local.properties +/.idea/caches +/.idea/libraries +/.idea/modules.xml +/.idea/workspace.xml +/.idea/navEditor.xml +/.idea/assetWizardSettings.xml +.DS_Store +/build +/captures +.externalNativeBuild +.cxx +local.properties +release/ + +/.idea/* +!/.idea/runConfigurations/ +/batch-maven-secret-gpg.key +/maven.properties \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..6d5b400 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,4 @@ +1.0.0-rc.1 +----- + + * Initial dispatcher release candidate. \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..520ed29 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2022-Present Batch.com + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..c5b03a8 --- /dev/null +++ b/Makefile @@ -0,0 +1,25 @@ +all: aar + +aar: clean + ./gradlew assembleRelease --no-build-cache && \ + mkdir -p release/ && \ + cp piano-dispatcher/build/outputs/aar/piano-dispatcher-release.aar release/ && \ + cp LICENSE release/ + +clean: + ./gradlew clean + rm -rf release/ + +test: + ./gradlew testDebugUnitTest + +lint: + ./gradlew lintDebug + +ci: clean lint test aar + +publish: aar + ./gradlew piano-dispatcher:publish + +.PHONY: test aar + diff --git a/README.md b/README.md new file mode 100644 index 0000000..bb595ab --- /dev/null +++ b/README.md @@ -0,0 +1,34 @@ +[![Maven Central](https://maven-badges.herokuapp.com/maven-central/com.batch.android/piano-dispatcher/badge.svg)](https://maven-badges.herokuapp.com/maven-central/com.batch.android/piano-dispatcher) +[![Github Action](https://github.com/BatchLabs/Batch-Android-piano-dispatcher/workflows/Android%20CI/badge.svg)](https://github.com/BatchLabs/Batch-Android-piano-dispatcher/actions?query=workflow%3A%22Android+CI%22) + +Batch.com Android Piano Analytics Dispatcher +================== + +![Logo](https://static.batch.com/documentation/Readmes/logo_batch_full_178.png) + +# About + +A "ready-to-go" dispatcher that reflect event from the Batch SDK to the Piano dashboard using AT and UTM tags. + +# Requirements + - Android Studio 3.0+ + - Android 16+ + - Batch Android SDK 1.19.3+ + - Piano Analytics 3.1.0+ + +# Installation +Gradle (recommended) + +``` +implementation 'com.batch.android:piano-dispatcher:1.0.0' +``` + +Read our [setup documentation](https://doc.batch.com/) to follow a step by step tutorial for integrating Batch features into your app. + +# Documentation + + - [Technical](https://batch.com/doc) + - [FAQ](https://batch.com/doc/faq/general.html) + - [Developer FAQ](https://batch.com/developers) + +Copyright 2020 - Batch.com \ No newline at end of file diff --git a/build.gradle b/build.gradle new file mode 100644 index 0000000..9c0136a --- /dev/null +++ b/build.gradle @@ -0,0 +1,4 @@ +// Top-level build file where you can add configuration options common to all sub-projects/modules. +plugins { + id 'com.android.library' version '7.3.0' apply false +} \ No newline at end of file diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..3e927b1 --- /dev/null +++ b/gradle.properties @@ -0,0 +1,21 @@ +# Project-wide Gradle settings. +# IDE (e.g. Android Studio) users: +# Gradle settings configured through the IDE *will override* +# any settings specified in this file. +# For more details on how to configure your build environment visit +# http://www.gradle.org/docs/current/userguide/build_environment.html +# Specifies the JVM arguments used for the daemon process. +# The setting is particularly useful for tweaking memory settings. +org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 +# When configured, Gradle will run in incubating parallel mode. +# This option should only be used with decoupled projects. More details, visit +# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects +# org.gradle.parallel=true +# AndroidX package structure to make it clearer which packages are bundled with the +# Android operating system, and which are packaged with your app's APK +# https://developer.android.com/topic/libraries/support-library/androidx-rn +android.useAndroidX=true +# Enables namespacing of each library's R class so that its R class includes only the +# resources declared in the library itself and none from the library's dependencies, +# thereby reducing the size of the R class for that library +android.nonTransitiveRClass=true \ No newline at end of file diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..e708b1c Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..4bec442 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,6 @@ +#Tue Nov 08 14:28:42 CET 2022 +distributionBase=GRADLE_USER_HOME +distributionUrl=https\://services.gradle.org/distributions/gradle-7.4-bin.zip +distributionPath=wrapper/dists +zipStorePath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..4f906e0 --- /dev/null +++ b/gradlew @@ -0,0 +1,185 @@ +#!/usr/bin/env sh + +# +# Copyright 2015 the original author or authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://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. +# + +############################################################################## +## +## Gradle start up script for UN*X +## +############################################################################## + +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +PRG="$0" +# Need this for relative symlinks. +while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi +done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" >/dev/null +APP_HOME="`pwd -P`" +cd "$SAVED" >/dev/null + +APP_NAME="Gradle" +APP_BASE_NAME=`basename "$0"` + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD="maximum" + +warn () { + echo "$*" +} + +die () { + echo + echo "$*" + echo + exit 1 +} + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "`uname`" in + CYGWIN* ) + cygwin=true + ;; + Darwin* ) + darwin=true + ;; + MINGW* ) + msys=true + ;; + NONSTOP* ) + nonstop=true + ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD="java" + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then + MAX_FD_LIMIT=`ulimit -H -n` + if [ $? -eq 0 ] ; then + if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then + MAX_FD="$MAX_FD_LIMIT" + fi + ulimit -n $MAX_FD + if [ $? -ne 0 ] ; then + warn "Could not set maximum file descriptor limit: $MAX_FD" + fi + else + warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" + fi +fi + +# For Darwin, add options to specify how the application appears in the dock +if $darwin; then + GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" +fi + +# For Cygwin or MSYS, switch paths to Windows format before running java +if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then + APP_HOME=`cygpath --path --mixed "$APP_HOME"` + CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` + + JAVACMD=`cygpath --unix "$JAVACMD"` + + # We build the pattern for arguments to be converted via cygpath + ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` + SEP="" + for dir in $ROOTDIRSRAW ; do + ROOTDIRS="$ROOTDIRS$SEP$dir" + SEP="|" + done + OURCYGPATTERN="(^($ROOTDIRS))" + # Add a user-defined pattern to the cygpath arguments + if [ "$GRADLE_CYGPATTERN" != "" ] ; then + OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" + fi + # Now convert the arguments - kludge to limit ourselves to /bin/sh + i=0 + for arg in "$@" ; do + CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` + CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option + + if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition + eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` + else + eval `echo args$i`="\"$arg\"" + fi + i=`expr $i + 1` + done + case $i in + 0) set -- ;; + 1) set -- "$args0" ;; + 2) set -- "$args0" "$args1" ;; + 3) set -- "$args0" "$args1" "$args2" ;; + 4) set -- "$args0" "$args1" "$args2" "$args3" ;; + 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; + 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; + 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; + 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; + 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; + esac +fi + +# Escape application args +save () { + for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done + echo " " +} +APP_ARGS=`save "$@"` + +# Collect all arguments for the java command, following the shell quoting and substitution rules +eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..ac1b06f --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,89 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/piano-dispatcher/.gitignore b/piano-dispatcher/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/piano-dispatcher/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/piano-dispatcher/build.gradle b/piano-dispatcher/build.gradle new file mode 100644 index 0000000..c0bfe64 --- /dev/null +++ b/piano-dispatcher/build.gradle @@ -0,0 +1,63 @@ +plugins { + id 'com.android.library' +} + +ext { + mavenGroupId = 'com.batch.android' + mavenArtifact = 'piano-dispatcher' + powerMockVersion = '2.0.7' +} + +android { + namespace 'com.batch.android.dispatcher.piano' + compileSdk 33 + + defaultConfig { + minSdk 16 + targetSdk 33 + versionCode 1 + versionName "1.0.0-rc.1" + + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + } + + buildTypes { + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' + } + } + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } + + testOptions { + unitTests { + includeAndroidResources = true + all { + testLogging { + events "started", "passed", "skipped", "failed" + } + } + } + } +} + +dependencies { + + api 'com.batch.android:batch-sdk:1.19.3' + api 'io.piano:analytics:3.2.0' + + testImplementation 'junit:junit:4.13.2' + testImplementation 'androidx.test.ext:junit:1.1.4' + testImplementation 'org.mockito:mockito-core:3.4.6' + testImplementation 'org.mockito:mockito-inline:3.4.6' // Mockito extension to mock final class + testImplementation 'org.robolectric:robolectric:4.7.1' + testImplementation 'androidx.test.espresso:espresso-core:3.5.0' + testImplementation "org.powermock:powermock-module-junit4:$powerMockVersion" + testImplementation "org.powermock:powermock-module-junit4-rule:$powerMockVersion" + testImplementation "org.powermock:powermock-api-mockito2:$powerMockVersion" + testImplementation "org.powermock:powermock-classloading-xstream:$powerMockVersion" +} +apply from: 'maven-publish.gradle' \ No newline at end of file diff --git a/piano-dispatcher/maven-publish.gradle b/piano-dispatcher/maven-publish.gradle new file mode 100644 index 0000000..32acf1a --- /dev/null +++ b/piano-dispatcher/maven-publish.gradle @@ -0,0 +1,164 @@ +apply plugin: 'maven-publish' +apply plugin: 'signing' + +def propFile = project.rootProject.file("maven.properties") +Properties props = new Properties() +if (propFile.exists()) { + props.load(propFile.newDataInputStream()) + allprojects { + ext."signing.password" = props.getProperty("batch.maven.central.signing.password", "") + } +} + +def signingFile = project.rootProject.file("batch-maven-secret-gpg.key") +if (signingFile.exists()) { + allprojects { + ext."signing.keyId" = "561B6D31" + ext."signing.secretKeyRingFile" = signingFile.getAbsolutePath() + } +} + +task androidJavadocs(type: Javadoc) { + source = android.sourceSets.main.java.srcDirs + classpath += project.files(android.getBootClasspath().join(File.pathSeparator)) + android.libraryVariants.all { variant -> + if (variant.name == 'release') { + owner.classpath += variant.getCompileClasspath() + } + } + exclude '**/R.html', '**/R.*.html', '**/index.html' +} + +task androidJavadocsJar(type: Jar, dependsOn: androidJavadocs) { + archiveClassifier.set("javadoc") + from androidJavadocs.destinationDir +} + +task androidSourcesJar(type: Jar) { + archiveClassifier.set("sources") + from android.sourceSets.main.java.srcDirs +} + +publishing { + publications { + pianodispatcher(MavenPublication) { + + groupId mavenGroupId + artifactId mavenArtifact + version android.defaultConfig.versionName + + artifact "$buildDir/outputs/aar/piano-dispatcher-release.aar" + artifact androidJavadocsJar + artifact androidSourcesJar + + pom { + name = "Batch-Android-piano-dispatcher" + packaging = "aar" + description = "Batch.com's Android Piano Analytics Dispatcher main artifact" + url = "https://batch.com" + + scm { + url = "https://github.com/BatchLabs/Batch-Android-piano-dispatcher" + connection = "scm:git:https://github.com/BatchLabs/Batch-Android-piano-dispatcher.git" + developerConnection = "scm:git:https://github.com/BatchLabs/Batch-Android-piano-dispatcher.git" + } + + licenses { + license { + name = "MIT License - Copyright (c) 2022-Present Batch.com" + distribution = "repo" + comments = "Permission is hereby granted, free of charge, to any person obtaining a copy\n" + + "of this software and associated documentation files (the \"Software\"), to deal\n" + + "in the Software without restriction, including without limitation the rights\n" + + "to use, copy, modify, merge, publish, distribute, sublicense, and/or sell\n" + + "copies of the Software, and to permit persons to whom the Software is\n" + + "furnished to do so, subject to the following conditions:\n" + + "\n" + + "The above copyright notice and this permission notice shall be included in\n" + + "all copies or substantial portions of the Software.\n" + + "\n" + + "THE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\n" + + "IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\n" + + "FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\n" + + "AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\n" + + "LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\n" + + "OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN\n" + + "THE SOFTWARE.\n" + } + } + + developers { + developer { + id = "batch-tech" + name = "Batch.com Tech" + } + } + + organization { + name = "Batch.com" + url = "https://batch.com" + } + } + + pom.withXml { + final dependenciesNode = asNode().appendNode('dependencies') + ext.addDependency = { Dependency dep, String scope -> + if (dep.group == null || dep.version == null || dep.name == null || dep.name == "unspecified") + return // ignore invalid dependencies + + final dependencyNode = dependenciesNode.appendNode('dependency') + dependencyNode.appendNode('groupId', dep.group) + dependencyNode.appendNode('artifactId', dep.name) + dependencyNode.appendNode('version', dep.version) + dependencyNode.appendNode('scope', scope) + + if (!dep.transitive) { + // If this dependency is transitive, we should force exclude all its dependencies them from the POM + final exclusionNode = dependencyNode.appendNode('exclusions').appendNode('exclusion') + exclusionNode.appendNode('groupId', '*') + exclusionNode.appendNode('artifactId', '*') + } else if (!dep.properties.excludeRules.empty) { + // Otherwise add specified exclude rules + final exclusionNode = dependencyNode.appendNode('exclusions').appendNode('exclusion') + dep.properties.excludeRules.each { ExcludeRule rule -> + exclusionNode.appendNode('groupId', rule.group ?: '*') + exclusionNode.appendNode('artifactId', rule.module ?: '*') + } + } + } + + configurations.api.getDependencies().each { dep -> addDependency(dep, "compile") } + configurations.implementation.getDependencies().each { dep -> addDependency(dep, "runtime") } + } + } + } + + repositories { + maven { + if (!props.getProperty("batch.maven.central.release_repo_url", "").equals("")) { + url props.getProperty("batch.maven.central.release_repo_url", "") + credentials { + username = props.getProperty("batch.maven.central.username", "") + password = props.getProperty("batch.maven.central.password", "") + } + } else if (project.gradle.startParameter.taskNames.contains('publish')) { + logger.warn("WARNING: Could not get maven repository url, are you sure to have correctly setup " + + propFile.path + " and " + + signingFile.path + " ?") + } + } + } +} + +signing { + sign publishing.publications.pianodispatcher +} + +// Do not sign if we run the publishToMavenLocal task, useful for dev +tasks.withType(Sign) { + onlyIf { + !project.gradle.startParameter.taskNames.contains('publishToMavenLocal') && + !project.gradle.startParameter.taskNames.contains('sdk:publishToMavenLocal') + } +} + diff --git a/piano-dispatcher/proguard-rules.pro b/piano-dispatcher/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/piano-dispatcher/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/piano-dispatcher/src/main/AndroidManifest.xml b/piano-dispatcher/src/main/AndroidManifest.xml new file mode 100644 index 0000000..6deba06 --- /dev/null +++ b/piano-dispatcher/src/main/AndroidManifest.xml @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/piano-dispatcher/src/main/java/com/batch/android/dispatcher/piano/PianoDispatcher.java b/piano-dispatcher/src/main/java/com/batch/android/dispatcher/piano/PianoDispatcher.java new file mode 100644 index 0000000..1329e05 --- /dev/null +++ b/piano-dispatcher/src/main/java/com/batch/android/dispatcher/piano/PianoDispatcher.java @@ -0,0 +1,477 @@ +package com.batch.android.dispatcher.piano; + +import android.content.Context; +import android.net.Uri; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.annotation.VisibleForTesting; + +import com.batch.android.Batch; +import com.batch.android.BatchEventDispatcher; + +import java.util.HashMap; +import java.util.Map; +import java.util.Set; + +import io.piano.analytics.Event; +import io.piano.analytics.PianoAnalytics; + +/** + * Piano Event Dispatcher + * + * Dispatch Batch events to the Piano Analytics SDK. By default events are dispatched as On-site Ads. + * If you want to dispatch as custom event, please see {@link PianoDispatcher#enableBatchCustomEvents(boolean)}. + * Note: if you enable custom events, you need to declare them in your Piano Data Model. + */ +public class PianoDispatcher implements BatchEventDispatcher { + + /** + * Batch internal dispatcher information used for analytics + */ + private static final String DISPATCHER_NAME = "piano"; + private static final int DISPATCHER_VERSION = 1; + + /** + * Piano event keys + */ + private static final String CAMPAIGN = "src_campaign"; + private static final String SOURCE = "src_source"; + private static final String SOURCE_FORCE = "src_force"; + private static final String MEDIUM = "src_medium"; + private static final String CONTENT = "src_content"; + + private static final String EVENT_IMPRESSION = "publisher.impression"; + private static final String EVENT_CLICK = "publisher.click"; + + private static final String ON_SITE_TYPE = "onsitead_type"; + private static final String ON_SITE_TYPE_PUBLISHER = "Publisher"; + private static final String ON_SITE_ADVERTISER = "onsitead_advertiser"; + private static final String ON_SITE_CAMPAIGN = "onsitead_campaign"; + private static final String ON_SITE_FORMAT = "onsitead_format"; + + /** + * Custom event name used when logging on Piano + * + * Note: Must be add as custom event/property in your Piano Data Model + */ + private static final String NOTIFICATION_DISPLAY_NAME = "batch_notification_display"; + private static final String NOTIFICATION_OPEN_NAME = "batch_notification_open"; + private static final String NOTIFICATION_DISMISS_NAME = "batch_notification_dismiss"; + private static final String MESSAGING_SHOW_NAME = "batch_in_app_show"; + private static final String MESSAGING_CLOSE_NAME = "batch_in_app_close"; + private static final String MESSAGING_AUTO_CLOSE_NAME = "batch_in_app_auto_close"; + private static final String MESSAGING_CLOSE_ERROR_NAME = "batch_in_app_close_error"; + private static final String MESSAGING_CLICK_NAME = "batch_in_app_click"; + private static final String MESSAGING_WEBVIEW_CLICK_NAME = "batch_in_app_webview_click"; + private static final String BATCH_WEBVIEW_ANALYTICS_ID = "batch_webview_analytics_id"; + private static final String BATCH_TRACKING_ID = "batch_tracking_id"; + private static final String UNKNOWN_EVENT_NAME = "batch_unknown"; + + /** + * Batch event values + */ + private static final String BATCH_SRC = "Batch"; + private static final String BATCH_FORMAT_IN_APP = "in-app"; + private static final String BATCH_FORMAT_PUSH = "push"; + private static final String BATCH_DEFAULT_CAMPAIGN = "batch-default-campaign"; + + /** + * Third-party keys + */ + private static final String AT_MEDIUM = "at_medium"; + private static final String AT_CAMPAIGN = "at_campaign"; + + private static final String UTM_SOURCE = "utm_source"; + private static final String UTM_MEDIUM = "utm_medium"; + private static final String UTM_CAMPAIGN = "utm_campaign"; + private static final String UTM_CONTENT = "utm_content"; + + /** + * Piano Analytics instance + */ + private final PianoAnalytics pianoAnalytics; + + /** + * Whether Batch should send custom events (default: false) + * + * Note: Custom events must be defined in the Piano Data Model + */ + private boolean customEventsEnabled = false; + + /** + * Whether Batch should handle UTM tags in campaign's deeplink + * and custom payload. (default = true) + */ + private boolean isUTMTrackingEnabled = true; + + /** + * Constructor + * + * @param context application context + */ + public PianoDispatcher(@NonNull Context context) { + pianoAnalytics = PianoAnalytics.getInstance(context); + } + + /** + * Whether Batch should dispatch as Piano Custom Event. + * + * Note: This method should be called the as soon as possible in your Application subclass + * just after your Piano Configuration. + * + * @param enabled true if you want to enable custom events + */ + public void enableBatchCustomEvents(boolean enabled) { + this.customEventsEnabled = enabled; + } + + /** + * Whether Batch should handle UTM tags in campaign's deeplink + * and custom payload. (default = true) + */ + public void enableUTMTracking(boolean enabled) { + this.isUTMTrackingEnabled = enabled; + } + + /** + * Get the analytics name of this dispatcher + * + * @return The name + */ + @Override + public String getName() { + return DISPATCHER_NAME; + } + + /** + * Get the analytics version of this dispatcher + * + * @return The version + */ + @Override + public int getVersion() { + return DISPATCHER_VERSION; + } + + /** + * Callback fired when a new Batch event is triggered + * + * @param type The type of the event + * @param payload The associated payload of the event + */ + @Override + public void dispatchEvent(@NonNull Batch.EventDispatcher.Type type, @NonNull Batch.EventDispatcher.Payload payload) { + + // Dispatch onSiteAds event + if (shouldBeDispatchedAsOnSiteAd(type)) { + Event onSiteAdsEvent = buildPianoOnSiteAdsEvent(type, payload); + if (onSiteAdsEvent != null) { + pianoAnalytics.sendEvent(onSiteAdsEvent); + } + } + + // Dispatch Custom Event if enabled + if (customEventsEnabled) { + Event event = buildPianoCustomEvent(type, payload); + pianoAnalytics.sendEvent(event); + } + } + + /** + * Build an On-Site Ads Piano Event from a Batch Event + * + * @param type Batch event type + * @param payload Batch event payload + * @return The Piano event to send + */ + @Nullable + @VisibleForTesting + Event buildPianoOnSiteAdsEvent(Batch.EventDispatcher.Type type, Batch.EventDispatcher.Payload payload) { + + String pianoOnSiteEventName; + if (isImpression(type)) { + pianoOnSiteEventName = EVENT_IMPRESSION; + } else if (isClick(type)) { + pianoOnSiteEventName = EVENT_CLICK; + } else { + return null; + } + + HashMap params = new HashMap() {{ + put(ON_SITE_TYPE, ON_SITE_TYPE_PUBLISHER); + put(ON_SITE_ADVERTISER, getSource(payload)); + put(ON_SITE_CAMPAIGN, getCampaign(payload)); + put(ON_SITE_FORMAT, getMedium(payload, type)); + }}; + return new Event(pianoOnSiteEventName, params); + } + + /** + * Build a Piano Custom Event from a Batch Event + * + * @param type Batch event type + * @param payload Batch event payload + * @return The Piano event to send + */ + @VisibleForTesting + Event buildPianoCustomEvent(@NonNull Batch.EventDispatcher.Type type, + @NonNull Batch.EventDispatcher.Payload payload) { + + String eventName = getPianoEventName(type); + HashMap eventData = new HashMap() {{ + put(CAMPAIGN, getCampaign(payload)); + put(MEDIUM, getMedium(payload, type)); + put(SOURCE, getSource(payload)); + put(SOURCE_FORCE, true); + }}; + + String trackingId = payload.getTrackingId(); + if (trackingId != null && !trackingId.isEmpty()) { + eventData.put(BATCH_TRACKING_ID, trackingId); + } + + String content = getContent(payload); + if (content != null && !content.isEmpty()) { + eventData.put(CONTENT, content); + } + + if (type.isMessagingEvent()) { + String webViewAnalyticsId = payload.getWebViewAnalyticsID(); + if (webViewAnalyticsId != null && !webViewAnalyticsId.isEmpty()) { + eventData.put(BATCH_WEBVIEW_ANALYTICS_ID, webViewAnalyticsId); + } + } + return new Event(eventName, eventData); + } + + /** + * Get the campaign label to send at Piano. + * + * First, check for an "at_campaign" tag in the custom payload, or in the deeplink. + * If not, check for an "utm_campaign" tag in the custom payload, or in the deeplink. + * If not, check if there is a TrackingID attached. + * If not, use {@link PianoDispatcher#BATCH_DEFAULT_CAMPAIGN} + * + * @param payload Batch event payload + * @return The campaign label. + */ + @NonNull + @VisibleForTesting + String getCampaign(@NonNull Batch.EventDispatcher.Payload payload) { + String campaign = getTagFromPayload(payload, AT_CAMPAIGN); + if (campaign != null && !campaign.isEmpty()) { + return campaign; + } + campaign = getTagFromPayload(payload, UTM_CAMPAIGN); + if (isUTMTrackingEnabled && campaign != null && !campaign.isEmpty()) { + return campaign; + } + campaign = payload.getTrackingId(); + if (campaign != null && !campaign.isEmpty()) { + return campaign; + } + return BATCH_DEFAULT_CAMPAIGN; + } + + /** + * Get the medium + * + * Check for at_medium or utm_medium tags + * If not found return "push" or "in-app" according to the batch event type + * + * @param payload Batch event payload + * @param type Batch event type + * @return The medium + */ + @NonNull + private String getMedium(@NonNull Batch.EventDispatcher.Payload payload, @NonNull Batch.EventDispatcher.Type type) { + String medium; + medium = getTagFromPayload(payload, AT_MEDIUM); + if (medium != null && !medium.isEmpty()) { + return medium; + } + medium = getTagFromPayload(payload, UTM_MEDIUM); + if (isUTMTrackingEnabled && medium != null && !medium.isEmpty()) { + return medium; + } + if (type.isNotificationEvent()) { + medium = BATCH_FORMAT_PUSH; + } else { + medium = BATCH_FORMAT_IN_APP; + } + return medium; + } + + /** + * Get the source + * + * Check for utm_source tags + * If not found return "Batch" + * + * @param payload Batch event payload + * @return The source + */ + @NonNull + private String getSource(@NonNull Batch.EventDispatcher.Payload payload) { + String source = getTagFromPayload(payload, UTM_SOURCE); + if (isUTMTrackingEnabled && source != null && !source.isEmpty()) { + return source; + } + return BATCH_SRC; + } + + /** + * Get the content + * + * Check for utm_content tag + * If not found return null + * + * @param payload Batch event payload + * @return The content + */ + @Nullable + private String getContent(@NonNull Batch.EventDispatcher.Payload payload) { + String content = getTagFromPayload(payload, UTM_CONTENT); + if (isUTMTrackingEnabled && content != null && !content.isEmpty()) { + return content; + } + return null; + } + + /** + * Get the corresponding Piano event name. + * + * @param type Batch event type + * @return The corresponding event name for Piano + */ + @NonNull + private String getPianoEventName(Batch.EventDispatcher.Type type) { + switch (type) { + case NOTIFICATION_DISPLAY: + return NOTIFICATION_DISPLAY_NAME; + case NOTIFICATION_OPEN: + return NOTIFICATION_OPEN_NAME; + case NOTIFICATION_DISMISS: + return NOTIFICATION_DISMISS_NAME; + case MESSAGING_SHOW: + return MESSAGING_SHOW_NAME; + case MESSAGING_CLOSE: + return MESSAGING_CLOSE_NAME; + case MESSAGING_AUTO_CLOSE: + return MESSAGING_AUTO_CLOSE_NAME; + case MESSAGING_CLOSE_ERROR: + return MESSAGING_CLOSE_ERROR_NAME; + case MESSAGING_CLICK: + return MESSAGING_CLICK_NAME; + case MESSAGING_WEBVIEW_CLICK: + return MESSAGING_WEBVIEW_CLICK_NAME; + } + return UNKNOWN_EVENT_NAME; + } + + /** + * Indicate if an event type should be dispatched as On-site Ads + * + * @param type Batch event type + * @return True if this kind of event should be dispatched as On-site Ads. + */ + private boolean shouldBeDispatchedAsOnSiteAd(Batch.EventDispatcher.Type type) { + return isImpression(type) || isClick(type); + } + + /** + * Whether this kind of Batch event corresponds to a Piano publisher impression event + * + * @param type Batch event type + * @return True if it's an impression + */ + private boolean isImpression(Batch.EventDispatcher.Type type) { + return type.equals(Batch.EventDispatcher.Type.NOTIFICATION_DISPLAY) || + type.equals(Batch.EventDispatcher.Type.MESSAGING_SHOW); + } + + /** + * Whether this kind of Batch event corresponds to a Piano publisher click event + * + * @param type Batch event type + * @return True if it's a click + */ + private boolean isClick(Batch.EventDispatcher.Type type) { + return type.equals(Batch.EventDispatcher.Type.NOTIFICATION_OPEN) || + type.equals(Batch.EventDispatcher.Type.MESSAGING_CLICK) || + type.equals(Batch.EventDispatcher.Type.MESSAGING_WEBVIEW_CLICK); + } + + /** + * Simple helper method to get a tag from a Batch event payload. + * + * @param payload Batch event payload + * @param tagName Tag name + * @return The tag + */ + @Nullable + private String getTagFromPayload(@NonNull Batch.EventDispatcher.Payload payload, @NonNull String tagName) { + String tag = getTagFromDeeplink(payload, tagName); + + String tagTmp = payload.getCustomValue(tagName); + if (tagTmp != null) { + tag = tagTmp; + } + return tag; + } + + /** + * Simple helper method to get a tag from a Batch deeplink. + * + * @param payload Batch event payload + * @param tagName Tag name + * @return The tag + */ + @Nullable + private String getTagFromDeeplink(Batch.EventDispatcher.Payload payload, String tagName) { + String tag = null; + String deeplink = payload.getDeeplink(); + if (deeplink != null) { + deeplink = deeplink.trim(); + tagName = tagName.toLowerCase(); + Uri uri = Uri.parse(deeplink); + if (uri.isHierarchical()) { + String fragment = uri.getFragment(); + if (fragment != null && !fragment.isEmpty()) { + Map fragments = getFragmentMap(fragment); + String tagTmp = fragments.get(tagName); + if (tagTmp != null) { + tag = tagTmp; + } + } + Set keys = uri.getQueryParameterNames(); + for (String key : keys) { + if (tagName.equalsIgnoreCase(key)) { + return uri.getQueryParameter(key); + } + } + } + } + return tag; + } + + /** + * Simple helper method to get a fragment as a map. + * + * @param fragment url fragment + * @return the map + */ + @NonNull + private Map getFragmentMap(String fragment) { + String[] params = fragment.split("&"); + Map map = new HashMap<>(); + for (String param : params) { + String[] parts = param.split("="); + if (parts.length >= 2) { + map.put(parts[0].toLowerCase(), parts[1]); + } + } + return map; + } +} diff --git a/piano-dispatcher/src/main/java/com/batch/android/dispatcher/piano/PianoRegistrar.java b/piano-dispatcher/src/main/java/com/batch/android/dispatcher/piano/PianoRegistrar.java new file mode 100644 index 0000000..f380d71 --- /dev/null +++ b/piano-dispatcher/src/main/java/com/batch/android/dispatcher/piano/PianoRegistrar.java @@ -0,0 +1,56 @@ +package com.batch.android.dispatcher.piano; + +import android.content.Context; +import android.content.pm.ApplicationInfo; +import android.content.pm.PackageManager; +import android.util.Log; + +import com.batch.android.BatchEventDispatcher; +import com.batch.android.eventdispatcher.DispatcherRegistrar; + +public class PianoRegistrar implements DispatcherRegistrar { + + private static PianoDispatcher instance = null; + + /** + * Meta-data name to enable custom events + */ + private static final String CUSTOM_EVENT_ENABLED_METADATA = "com.batch.android.dispatcher.piano.enable_custom_events"; + + /** + * Meta-data name to enable utm tracking + */ + private static final String UTM_TRACKING_ENABLED_METADATA = "com.batch.android.dispatcher.piano.enable_utm_tracking"; + + @Override + public BatchEventDispatcher getDispatcher(Context context) { + if (instance == null) { + instance = new PianoDispatcher(context); + instance.enableBatchCustomEvents(getBooleanMetaDataInfo(context, CUSTOM_EVENT_ENABLED_METADATA, false)); + instance.enableUTMTracking(getBooleanMetaDataInfo(context, UTM_TRACKING_ENABLED_METADATA, true)); + } + return instance; + } + + /** + * Get boolean meta-data value from Android's manifest. + * + * @param context Application context + * @param key Name of the meta-data + * @param fallback Default value to fallback + * @return the value found or the fallback + */ + private boolean getBooleanMetaDataInfo(Context context, String key, boolean fallback) { + try { + ApplicationInfo appInfo = context.getPackageManager().getApplicationInfo(context.getPackageName(), PackageManager.GET_META_DATA); + if (appInfo.metaData != null) { + return appInfo.metaData.getBoolean(key, fallback); + } + } catch (PackageManager.NameNotFoundException e) { + // if we can’t find it in the manifest, just return the fallback + } catch (Exception e) { + Log.e("Batch", "Error while parsing meta-data info", e); + } + return fallback; + } +} diff --git a/piano-dispatcher/src/test/java/com/batch/android/dispatcher/piano/PianoDispatcherTest.java b/piano-dispatcher/src/test/java/com/batch/android/dispatcher/piano/PianoDispatcherTest.java new file mode 100644 index 0000000..97cfeb6 --- /dev/null +++ b/piano-dispatcher/src/test/java/com/batch/android/dispatcher/piano/PianoDispatcherTest.java @@ -0,0 +1,316 @@ +package com.batch.android.dispatcher.piano; + +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mockito; +import org.powermock.api.mockito.PowerMockito; +import org.powermock.core.classloader.annotations.PowerMockIgnore; +import org.powermock.core.classloader.annotations.PrepareForTest; +import org.powermock.reflect.Whitebox; +import org.robolectric.annotation.Config; + +import android.os.Build; +import android.os.Bundle; + +import androidx.test.core.app.ApplicationProvider; +import androidx.test.ext.junit.runners.AndroidJUnit4; + +import com.batch.android.Batch; + +import java.util.HashMap; + +import io.piano.analytics.Event; +import io.piano.analytics.PianoAnalytics; + +@RunWith(AndroidJUnit4.class) +@Config(sdk = Build.VERSION_CODES.S) +@PowerMockIgnore({"org.powermock.*", "org.mockito.*", "org.robolectric.*", "android.*", "androidx.*"}) +@PrepareForTest(PianoAnalytics.class) +public class PianoDispatcherTest { + + private PianoDispatcher dispatcher; + + @Before + public void setUp() { + this.dispatcher = new PianoDispatcher(ApplicationProvider.getApplicationContext()); + } + + @Test + public void testGetCampaignFromTrackingID() { + TestEventPayload payload = new TestEventPayload("from_tracking_id", "https://test.com", new Bundle()); + Assert.assertEquals("from_tracking_id", dispatcher.getCampaign(payload)); + } + + @Test + public void testGetCampaignFromCustomPayload() { + Bundle customPayload = new Bundle(); + customPayload.putString("at_campaign", "from_payload"); + TestEventPayload payload = new TestEventPayload(null, "https://test.com?at_campaign=from_deeplink", customPayload); + Assert.assertEquals("from_payload", dispatcher.getCampaign(payload)); + } + + @Test + public void testGetCampaignFromDeeplink() { + TestEventPayload payload = new TestEventPayload(null, "https://test.com?at_campaign=from_deeplink", null); + Assert.assertEquals("from_deeplink", dispatcher.getCampaign(payload)); + } + + @Test + public void testGetCampaignFromWrongDeeplink() { + TestEventPayload payload = new TestEventPayload(null, "mailto:test@test.com?utm_campaign=wrong", null); + Assert.assertEquals("batch-default-campaign", dispatcher.getCampaign(payload)); + } + + @Test + public void testGetCampaignFromUTMCustomPayload() { + TestEventPayload payload = new TestEventPayload(null, "https://test.com?utm_campaign=expected", null); + Assert.assertEquals("expected", dispatcher.getCampaign(payload)); + } + + @Test + public void testGetCampaignFromUTMDeepLink() { + Bundle customPayload = new Bundle(); + customPayload.putString("utm_campaign", "expected"); + TestEventPayload payload = new TestEventPayload(null, null, customPayload); + Assert.assertEquals("expected", dispatcher.getCampaign(payload)); + } + + @Test + public void testBuildPianoCustomEventInApp() { + TestEventPayload payload = new TestEventPayload("campaign_label", null, null); + Event event = dispatcher.buildPianoCustomEvent(Batch.EventDispatcher.Type.MESSAGING_SHOW, payload); + HashMap expectedData = new HashMap() {{ + put("src_campaign", "campaign_label"); + put("src_source", "Batch"); + put("src_force", true); + put("src_medium", "in-app"); + put("batch_tracking_id", "campaign_label"); + + }}; + Assert.assertEquals("batch_in_app_show", event.getName()); + Assert.assertEquals(expectedData, event.getData()); + } + + @Test + public void testBuildPianoCustomEventPush() { + TestEventPayload payload = new TestEventPayload("campaign_label", null, null); + Event event = dispatcher.buildPianoCustomEvent(Batch.EventDispatcher.Type.NOTIFICATION_DISPLAY, payload); + HashMap expectedData = new HashMap() {{ + put("src_campaign", "campaign_label"); + put("src_source", "Batch"); + put("src_force", true); + put("src_medium", "push"); + put("batch_tracking_id", "campaign_label"); + }}; + Assert.assertEquals("batch_notification_display", event.getName()); + Assert.assertEquals(expectedData, event.getData()); + } + + @Test + public void testBuildPianoCustomEventFromDeeplinkAT() { + TestEventPayload payload = new TestEventPayload("batchTrackingId", "https://test.com?at_campaign=campaign_label&at_medium=email", null); + Event event = dispatcher.buildPianoCustomEvent(Batch.EventDispatcher.Type.NOTIFICATION_DISPLAY, payload); + HashMap expectedData = new HashMap() {{ + put("src_campaign", "campaign_label"); + put("src_source", "Batch"); + put("src_force", true); + put("src_medium", "email"); + put("batch_tracking_id", "batchTrackingId"); + }}; + Assert.assertEquals("batch_notification_display", event.getName()); + Assert.assertEquals(expectedData, event.getData()); + } + + @Test + public void testBuildPianoCustomEventFromDeeplinkFirebase() { + TestEventPayload payload = new TestEventPayload("batchTrackingId", "https://test.com?utm_campaign=campaign_label&utm_medium=email&utm_source=firebase&utm_content=content", null); + Event event = dispatcher.buildPianoCustomEvent(Batch.EventDispatcher.Type.NOTIFICATION_DISPLAY, payload); + HashMap expectedData = new HashMap() {{ + put("src_campaign", "campaign_label"); + put("src_source", "firebase"); + put("src_force", true); + put("src_medium", "email"); + put("src_content", "content"); + put("batch_tracking_id", "batchTrackingId"); + }}; + Assert.assertEquals("batch_notification_display", event.getName()); + Assert.assertEquals(expectedData, event.getData()); + } + + @Test + public void testBuildPianoCustomEventFromDeeplinkFirebaseFragment() { + TestEventPayload payload = new TestEventPayload("batchTrackingId", + "https://test.com#utm_campaign=campaign_label&utm_medium=email&utm_source=firebase&utm_content=content", + null); + Event event = dispatcher.buildPianoCustomEvent(Batch.EventDispatcher.Type.NOTIFICATION_DISPLAY, payload); + HashMap expectedData = new HashMap() {{ + put("src_campaign", "campaign_label"); + put("src_source", "firebase"); + put("src_force", true); + put("src_medium", "email"); + put("src_content", "content"); + put("batch_tracking_id", "batchTrackingId"); + }}; + Assert.assertEquals("batch_notification_display", event.getName()); + Assert.assertEquals(expectedData, event.getData()); + } + + @Test + public void testPriorityBuildPianoCustomEventFromDeeplink() { + TestEventPayload payload = new TestEventPayload("batchTrackingId", + "https://test.com?utm_campaign=campaign_label#utm_campaign=campaign_label2&utm_medium=email&utm_source=firebase&utm_content=content", + null); + Event event = dispatcher.buildPianoCustomEvent(Batch.EventDispatcher.Type.NOTIFICATION_DISPLAY, payload); + HashMap expectedData = new HashMap() {{ + put("src_campaign", "campaign_label"); + put("src_source", "firebase"); + put("src_force", true); + put("src_medium", "email"); + put("src_content", "content"); + put("batch_tracking_id", "batchTrackingId"); + }}; + Assert.assertEquals("batch_notification_display", event.getName()); + Assert.assertEquals(expectedData, event.getData()); + } + + @Test + public void testBuildPianoCustomEventFromPayloadAT() { + Bundle customPayload = new Bundle(); + customPayload.putString("at_campaign", "campaign_label"); + customPayload.putString("at_medium", "email"); + TestEventPayload payload = new TestEventPayload("batchTrackingId", null, customPayload); + Event event = dispatcher.buildPianoCustomEvent(Batch.EventDispatcher.Type.NOTIFICATION_DISPLAY, payload); + HashMap expectedData = new HashMap() {{ + put("src_campaign", "campaign_label"); + put("src_source", "Batch"); + put("src_force", true); + put("src_medium", "email"); + put("batch_tracking_id", "batchTrackingId"); + }}; + Assert.assertEquals("batch_notification_display", event.getName()); + Assert.assertEquals(expectedData, event.getData()); + } + + @Test + public void testBuildPianoCustomEventFromPayloadFirebase() { + Bundle customPayload = new Bundle(); + customPayload.putString("utm_campaign", "campaign_label"); + customPayload.putString("utm_medium", "email"); + customPayload.putString("utm_source", "firebase"); + customPayload.putString("utm_content", "content"); + TestEventPayload payload = new TestEventPayload("batchTrackingId", null, customPayload); + Event event = dispatcher.buildPianoCustomEvent(Batch.EventDispatcher.Type.NOTIFICATION_DISPLAY, payload); + HashMap expectedData = new HashMap() {{ + put("src_campaign", "campaign_label"); + put("src_source", "firebase"); + put("src_force", true); + put("src_medium", "email"); + put("src_content", "content"); + put("batch_tracking_id", "batchTrackingId"); + }}; + Assert.assertEquals("batch_notification_display", event.getName()); + Assert.assertEquals(expectedData, event.getData()); + } + + @Test + public void testUTMTrackingDisabled() { + Bundle customPayload = new Bundle(); + customPayload.putString("utm_campaign", "campaign_label"); + customPayload.putString("utm_medium", "email"); + customPayload.putString("utm_source", "firebase"); + customPayload.putString("utm_content", "content"); + TestEventPayload payload = new TestEventPayload("batchTrackingId", null, customPayload); + dispatcher.enableUTMTracking(false); + Event event = dispatcher.buildPianoCustomEvent(Batch.EventDispatcher.Type.NOTIFICATION_DISPLAY, payload); + HashMap expectedData = new HashMap() {{ + put("src_campaign", "batchTrackingId"); + put("src_source", "Batch"); + put("src_force", true); + put("src_medium", "push"); + put("batch_tracking_id", "batchTrackingId"); + }}; + Assert.assertEquals("batch_notification_display", event.getName()); + Assert.assertEquals(expectedData, event.getData()); + } + + @Test + public void testBuildPianoOnSiteAdsEventImpressionPush() { + TestEventPayload payload = new TestEventPayload("campaign_label", null, null); + Event event = dispatcher.buildPianoOnSiteAdsEvent(Batch.EventDispatcher.Type.NOTIFICATION_DISPLAY, payload); + HashMap expectedData = new HashMap() {{ + put("onsitead_type", "Publisher"); + put("onsitead_advertiser", "Batch"); + put("onsitead_campaign", "campaign_label"); + put("onsitead_format", "push"); + }}; + assert event != null; + Assert.assertEquals("publisher.impression", event.getName()); + Assert.assertEquals(expectedData, event.getData()); + } + + @Test + public void testBuildPianoOnSiteAdsEventClickInApp() { + TestEventPayload payload = new TestEventPayload("campaign_label", null, null); + Event event = dispatcher.buildPianoOnSiteAdsEvent(Batch.EventDispatcher.Type.MESSAGING_CLICK, payload); + HashMap expectedData = new HashMap() {{ + put("onsitead_type", "Publisher"); + put("onsitead_advertiser", "Batch"); + put("onsitead_campaign", "campaign_label"); + put("onsitead_format", "in-app"); + }}; + assert event != null; + Assert.assertEquals("publisher.click", event.getName()); + Assert.assertEquals(expectedData, event.getData()); + } + + @Test + public void testDispatchEventWithoutCustomEvents() { + PianoAnalytics pa = PowerMockito.mock(PianoAnalytics.class); + Whitebox.setInternalState(dispatcher, "pianoAnalytics", pa); + + TestEventPayload payload = new TestEventPayload("campaign_label", null, null); + HashMap expectedData = new HashMap() {{ + put("onsitead_type", "Publisher"); + put("onsitead_advertiser", "Batch"); + put("onsitead_campaign", "campaign_label"); + put("onsitead_format", "in-app"); + }}; + Event expectedEvent = new Event("publisher.click", expectedData); + dispatcher.dispatchEvent(Batch.EventDispatcher.Type.MESSAGING_CLICK, payload); + Mockito.verify(pa, Mockito.times(1)).sendEvent(Mockito.any()); + Mockito.verify(pa, Mockito.times(1)).sendEvent(PianoEventMockitoMatcher.eq(expectedEvent)); + } + + @Test + public void testDispatchEventWithCustomEvents() { + PianoAnalytics pa = PowerMockito.mock(PianoAnalytics.class); + Whitebox.setInternalState(dispatcher, "pianoAnalytics", pa); + + TestEventPayload payload = new TestEventPayload("campaign_label", null, null); + + HashMap onSiteEventExpectedData = new HashMap() {{ + put("onsitead_type", "Publisher"); + put("onsitead_advertiser", "Batch"); + put("onsitead_campaign", "campaign_label"); + put("onsitead_format", "in-app"); + }}; + Event expectedOnSiteAdsEvent = new Event("publisher.impression", onSiteEventExpectedData); + + HashMap customEventExpectedData = new HashMap() {{ + put("src_campaign", "campaign_label"); + put("src_source", "Batch"); + put("src_force", true); + put("src_medium", "in-app"); + put("batch_tracking_id", "campaign_label"); + }}; + Event expectedCustomEvent = new Event("batch_in_app_show", customEventExpectedData); + + dispatcher.enableBatchCustomEvents(true); + dispatcher.dispatchEvent(Batch.EventDispatcher.Type.MESSAGING_SHOW, payload); + Mockito.verify(pa, Mockito.times(2)).sendEvent(Mockito.any()); + Mockito.verify(pa, Mockito.times(1)).sendEvent(PianoEventMockitoMatcher.eq(expectedOnSiteAdsEvent)); + Mockito.verify(pa, Mockito.times(1)).sendEvent(PianoEventMockitoMatcher.eq(expectedCustomEvent)); + } +} \ No newline at end of file diff --git a/piano-dispatcher/src/test/java/com/batch/android/dispatcher/piano/PianoEventMockitoMatcher.java b/piano-dispatcher/src/test/java/com/batch/android/dispatcher/piano/PianoEventMockitoMatcher.java new file mode 100644 index 0000000..cdf2e69 --- /dev/null +++ b/piano-dispatcher/src/test/java/com/batch/android/dispatcher/piano/PianoEventMockitoMatcher.java @@ -0,0 +1,38 @@ +package com.batch.android.dispatcher.piano; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.batch.android.json.JSONObject; + +import org.mockito.ArgumentMatcher; +import org.mockito.Mockito; + +import io.piano.analytics.Event; + +public class PianoEventMockitoMatcher implements ArgumentMatcher { + + private final Event expected; + + public PianoEventMockitoMatcher(@NonNull Event expected) { + this.expected = expected; + } + + @Override + public boolean matches(Event argument) { + + if (argument == expected) { + return true; + } + + if (argument.equals(expected)) { + return true; + } + + return expected.getName().equals(argument.getName()) && expected.getData().equals(argument.getData()); + } + + public static Event eq(@NonNull Event expected) { + return Mockito.argThat(new PianoEventMockitoMatcher(expected)); + } +} diff --git a/piano-dispatcher/src/test/java/com/batch/android/dispatcher/piano/TestEventPayload.java b/piano-dispatcher/src/test/java/com/batch/android/dispatcher/piano/TestEventPayload.java new file mode 100644 index 0000000..0b6c86a --- /dev/null +++ b/piano-dispatcher/src/test/java/com/batch/android/dispatcher/piano/TestEventPayload.java @@ -0,0 +1,96 @@ +package com.batch.android.dispatcher.piano; + +import android.os.Bundle; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +import com.batch.android.Batch; +import com.batch.android.BatchMessage; +import com.batch.android.BatchPushPayload; + +public class TestEventPayload implements Batch.EventDispatcher.Payload { + + private final String trackingId; + private final String deeplink; + private final String webViewAnalyticsID; + private final Bundle customPayload; + private final boolean isPositive; + + TestEventPayload(String trackingId, + String deeplink, + Bundle customPayload) + { + this(trackingId, null, deeplink, customPayload, false); + } + + TestEventPayload(String trackingId, + String webViewAnalyticsID, + String deeplink, + Bundle customPayload) + { + this(trackingId, webViewAnalyticsID, deeplink, customPayload, false); + } + + TestEventPayload(String trackingId, + String webViewAnalyticsID, + String deeplink, + Bundle customPayload, + boolean isPositive) + { + this.trackingId = trackingId; + this.webViewAnalyticsID = webViewAnalyticsID; + this.deeplink = deeplink; + this.customPayload = customPayload; + this.isPositive = isPositive; + } + + @Nullable + @Override + public String getTrackingId() + { + return trackingId; + } + + @Nullable + @Override + public String getDeeplink() + { + return deeplink; + } + + @Override + public boolean isPositiveAction() + { + return isPositive; + } + + @Nullable + @Override + public String getCustomValue(@NonNull String key) + { + if (customPayload == null) { + return null; + } + return customPayload.getString(key); + } + + @Nullable + @Override + public BatchMessage getMessagingPayload() + { + return null; + } + + @Nullable + @Override + public BatchPushPayload getPushPayload() + { + return null; + } + + @Nullable + public String getWebViewAnalyticsID() { + return webViewAnalyticsID; + } +} diff --git a/settings.gradle b/settings.gradle new file mode 100644 index 0000000..280f452 --- /dev/null +++ b/settings.gradle @@ -0,0 +1,16 @@ +pluginManagement { + repositories { + gradlePluginPortal() + google() + mavenCentral() + } +} +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + } +} +rootProject.name = "piano-dispatcher" +include ':piano-dispatcher'