commit 78ee78b1df4032895d244c09718ab022efa9ff7e Author: pscb-dev Date: Mon Apr 1 13:12:41 2024 +0300 init diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..aa724b7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,15 @@ +*.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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..64cecf6 --- /dev/null +++ b/README.md @@ -0,0 +1,193 @@ +# ПСКБ Платежи Java/Kotlin + +Библиотека является дополнением к API системы интернет-эквайринга [ПСКБ "Платежи"](https://online.pscb.ru) +и позволяет подключить приём платежей по картам в приложениях на JVM (Java/Kotlin/Groovy) с минимальными усилиями. + +## Возможности + +На текущий момент библиотека поддерживает: + +- Оплата картами +- Google Pay + +## Подключение зависимостей + +#### Gradle + +```groovy +maven { + name "pscb" + url "https://nxs.pscb.ru/repository/pscb-releases/" +} + +dependencies { + implementation "ru.pscb:online-java:1.0.0" +} +``` + +#### Maven + +Прописать репозиторий: +```xml + + + + false + + pscb + https://nxs.pscb.ru/repository/pscb-releases/ + + +``` + +Добавить зависимость: +```xml + + + ru.pscb + online-java + 1.0.0 + + +``` + +## Интеграция + +#### Оплата картами + +* Создайте экземпляр `PSCBOnlineClient` с вашими настройками: + +```kotlin + val apiClient = DefaultPSCBOnlineClient( + environment = BackendEnvironment.SANDBOX, // или PRODUCTION + marketPlaceId = 123456, /* <-- Your MarketPlace ID */ + signingKey = "Your Signing Key" +) + +// или +val sandboxApi = PSCBOnlineClient.sandbox(/* Your MarketPlace ID */, "Your Signing Key") +val productionApi = PSCBOnlineClient.production(/* Your MarketPlace ID */, "Your Signing Key") +``` + +> `environment` - окружение, в рамках которого библиотека взаимодействует с сервисом. `SANDBOX` - тестовое +> окружение; `PRODUCTION` - продуктовое. +> `Your MarketPlace ID` - ваш идентификатор в системе ПСКБ-Онлайн. +> `Your Signing Key` - ваш ключ подписи запросов к системе. + +* Создайте экземпляр `Payment` + +```kotlin +val payment = Payment(amount = BigDecimal(1000.00), orderId = "Order-ID") +``` + +> `amount` - сумма в рублях. (_На текущий момент другая валюта не поддерживается._) +> `orderId` - уникальный идентификатор заказа в рамках магазина. + +_Для детальной информации, какие параметры присутствуют, смотрите документацию `Payment`._ + +* Для приёма оплаты картами создайте экземпляр класса `CardData`. + +```kotlin +val card = CardData( + "4761349750010326", + expiryDate = YearMonth.of(2022, 12), + cvCode = "851" +) +``` + +* Создайте токен запроса, используя экземпляр `PSCBOnlineClient`, созданный ранее. + +```kotlin +val request = client.makeRequestWithCardData(card, payment) +``` + +* Отправьте токен запроса на сервер ПСКБ-Онлайн. + +```kotlin +// Send request to backend +client.send(request) { response -> + println("Error ${response.error}") + println("Status ${response.status}") + println("Response ${response.response}") +} +``` + +> В случае успешного выполнения запроса, `response` будет содержать информацию о принятом платеже, его ID, состояние и +> прочие данные. + +##### Полный пример на Kotlin + +```kotlin +object App { + + private val formatter: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyyMMdd-HHmmss") + + @JvmStatic + fun main(vararg args: String) { + val client = PSCBOnlineClient.sandbox(marketPlaceId = 1234567890, signingKey = "111111") + val card = CardData("4761120010000491", YearMonth.of(2022, 2), "500") + val payment = Payment(BigDecimal(100), "APP-" + LocalDateTime.now().format(formatter)) + val request = client.makeRequestWithCardData(card, payment) + + val future = client.send(request) { error, response -> + println("Error $error") + println("Response $response") + } + + future.join() + } + +} +``` + +#### Google Pay + +1. Получите свой Google Merchant ID + +2. В своём `Activity` создайте экземпляр `com.google.android.gms.wallet.PaymentsClient` + + ```kotlin + paymentsClient = PSCBAPI.createPaymentClient(this, WalletConstants.ENVIRONMENT_TEST) + ``` + +3. Также там создайте экземпляр `ru.pscb.library.client.PSCBOnlineClient` + +```kotlin +apiClient = DefaultPSCBOnlineClient( + environment = BackendEnvironment.SANDBOX, // или PRODUCTION + marketPlaceId = 123456, /* <-- Your MarketPlace ID */ + signingKey = "Your Signing Key" +) +``` + +4. Обработайте намерие оплаты пользователя + +```kotlin +val request = PSCBAPI.makePaymentRequest("", price) + +AutoResolveHelper.resolveTask( + paymentsClient.loadPaymentData(request), this, LOAD_PAYMENT_DATA_REQUEST_CODE +) +``` + +5. Обработайте авторизацию пользователя и отправьте данные на бэкенд сервер + +```kotlin +val payment = Payment(price, Date().toString()) +val request = apiClient.makeRequestWithTokenData(paymentData, payment) +val toast = Toast(this) + +apiClient.send(request) { throwable, response -> + if (throwable != null) { + toast.setText("Rawr") + Log.d("HandlePaymentSuccess", "Failed to init a payment", throwable) + } + + if (response != null) { + toast.setText("Meow") + Log.d("HandlePaymentSuccess", "Backend response $response") + } + + toast.show() +} +``` \ No newline at end of file diff --git a/build.gradle b/build.gradle new file mode 100644 index 0000000..0bf7a52 --- /dev/null +++ b/build.gradle @@ -0,0 +1,29 @@ +// Top-level build file where you can add configuration options common to all sub-projects/modules. +buildscript { + ext.kotlin_version = "1.3.72" + repositories { + google() + jcenter() + } + dependencies { + classpath "com.android.tools.build:gradle:4.1.1" + classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" + + // NOTE: Do not place your application dependencies here; they belong + // in the individual module build.gradle files + } +} + +allprojects { + group "ru.pscb" + version "1.1.0" + + repositories { + google() + jcenter() + } +} + +task clean(type: Delete) { + delete rootProject.buildDir +} \ No newline at end of file diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..98bed16 --- /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 +# Automatically convert third-party libraries to use AndroidX +android.enableJetifier=true +# Kotlin code style for this project: "official" or "obsolete": +kotlin.code.style=official \ 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..f6b961f 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..4d36706 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,6 @@ +#Sun Jan 10 13:52:15 MSK 2021 +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-6.5-all.zip diff --git a/gradlew b/gradlew new file mode 100644 index 0000000..cccdd3d --- /dev/null +++ b/gradlew @@ -0,0 +1,172 @@ +#!/usr/bin/env sh + +############################################################################## +## +## 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="" + +# 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, switch paths to Windows format before running java +if $cygwin ; 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=$((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" + +# by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong +if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then + cd "$(dirname "$0")" +fi + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..f955316 --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,84 @@ +@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 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= + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto init + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto init + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:init +@rem Get command-line arguments, handling Windows variants + +if not "%OS%" == "Windows_NT" goto win9xME_args + +:win9xME_args +@rem Slurp the command line arguments. +set CMD_LINE_ARGS= +set _SKIP=2 + +:win9xME_args_slurp +if "x%~1" == "x" goto execute + +set CMD_LINE_ARGS=%* + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/library/.gitignore b/library/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/library/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/library/build.gradle b/library/build.gradle new file mode 100644 index 0000000..4f5d1de --- /dev/null +++ b/library/build.gradle @@ -0,0 +1,75 @@ +plugins { + id 'com.android.library' + id 'kotlin-android' + id 'maven-publish' +} + +android { + compileSdkVersion 30 + buildToolsVersion "30.0.2" + + defaultConfig { + minSdkVersion 23 + targetSdkVersion 30 + versionCode 1 + versionName "1.0" + + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + consumerProguardFiles "consumer-rules.pro" + } + + buildTypes { + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' + } + } + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } + kotlinOptions { + jvmTarget = '1.8' + } +} + +dependencies { + implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" + implementation 'androidx.core:core-ktx:1.3.2' + implementation 'androidx.appcompat:appcompat:1.2.0' + implementation 'com.google.android.material:material:1.2.1' + testImplementation 'junit:junit:4.+' + androidTestImplementation 'androidx.test.ext:junit:1.1.2' + androidTestImplementation 'androidx.test.espresso:espresso-core:3.3.0' + + // Google pay service + implementation "com.google.android.gms:play-services-wallet:18.1.2" + + // Library stuff: + implementation "com.github.kittinunf.fuel:fuel:2.3.0" +} + +publishing { + publications { + release(MavenPublication) { + groupId = 'ru.pscb' + artifactId = 'online-java' + version = '1.0.0' + + afterEvaluate { + from components.release + } + } + } + + repositories { + maven { + name = "nexus" + url "https://nxs.pscb.ru/repository/pscb-releases/" // Replace with your Nexus repository URL + credentials { + username findProperty("publisherUsername") + password findProperty("publisherPassword") + } + } + } +} \ No newline at end of file diff --git a/library/consumer-rules.pro b/library/consumer-rules.pro new file mode 100644 index 0000000..e69de29 diff --git a/library/proguard-rules.pro b/library/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/library/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/library/src/androidTest/java/ru/pscb/acquiring/ExampleInstrumentedTest.kt b/library/src/androidTest/java/ru/pscb/acquiring/ExampleInstrumentedTest.kt new file mode 100644 index 0000000..114f4d9 --- /dev/null +++ b/library/src/androidTest/java/ru/pscb/acquiring/ExampleInstrumentedTest.kt @@ -0,0 +1,26 @@ +package ru.pscb.acquiring + +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.ext.junit.runners.AndroidJUnit4 + +import org.junit.Test +import org.junit.runner.RunWith + +import org.junit.Assert.* + +/** + * Instrumented test, which will execute on an Android device. + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +@RunWith(AndroidJUnit4::class) +class ExampleInstrumentedTest { + + @Test + fun useAppContext() { + // Context of the app under test. + val appContext = InstrumentationRegistry.getInstrumentation().targetContext + assertEquals("ru.pscb.acquiring.test", appContext.packageName) + } + +} \ No newline at end of file diff --git a/library/src/main/AndroidManifest.xml b/library/src/main/AndroidManifest.xml new file mode 100644 index 0000000..f5b0bd0 --- /dev/null +++ b/library/src/main/AndroidManifest.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/library/src/main/java/library/Exceptions.kt b/library/src/main/java/library/Exceptions.kt new file mode 100644 index 0000000..1136021 --- /dev/null +++ b/library/src/main/java/library/Exceptions.kt @@ -0,0 +1,56 @@ +package ru.pscb.library + +/** + * Базовое исключение для данной библиотеки + */ +sealed class PSCBException(message: String?, cause: Throwable?) : RuntimeException(message, cause) { + + constructor() : this(null, null) + + constructor(message: String?) : this(message, null) + + constructor(cause: Throwable?) : this(null, cause) + +} + +/** + * Кидается в том случае, если данные для класса [[ru.pscb.library.data.Payment]] были переданы неверные. + */ +class InvalidPaymentData(exception: Throwable) : PSCBException(exception) + +/** + * Возникает, если конечный сервер не отдал никаких данных. + */ +class EmptyResponseBodyException: PSCBException("Backend server did not produce any sensible result.") + +/** + * Индикатор, что исключение возникло по другой причине (см. [cause]) + */ +class CausedByException(exception: Throwable) : PSCBException(exception) + +/** + * Неопределённое исключение. + * Возникает в крайне редких случаях, когда сервер отдал корректный ответ, но не с корректными данными. + */ +class UndefinedException: PSCBException("Unknown cause") + +/** + * Общий признак для ошибок, возникших на стороне сервера ПСКБ-Онлайн + */ +class PSCBBackendException(code: String, description: String): PSCBException("Backend server responded with error code=$code ($description)") + +object PSCBErrors { + + fun noData() = EmptyResponseBodyException() + + fun cause(other: Throwable?): Throwable { + return if (other == null) { + UndefinedException() + } else { + CausedByException(other) + } + } + + fun backend(code: String?, description: String?): Throwable = PSCBBackendException(code ?: "N/A", description ?: "") + +} \ No newline at end of file diff --git a/library/src/main/java/library/client/DefaultPSCBOnlineClient.kt b/library/src/main/java/library/client/DefaultPSCBOnlineClient.kt new file mode 100644 index 0000000..77d6d07 --- /dev/null +++ b/library/src/main/java/library/client/DefaultPSCBOnlineClient.kt @@ -0,0 +1,146 @@ +package ru.pscb.library.client + +import android.util.Base64 +import com.github.kittinunf.fuel.Fuel +import com.github.kittinunf.fuel.core.extensions.jsonBody +import com.github.kittinunf.fuel.core.requests.CancellableRequest +import com.google.android.gms.wallet.PaymentData +import org.json.JSONException +import org.json.JSONObject +import ru.pscb.library.EmptyResponseBodyException +import ru.pscb.library.InvalidPaymentData +import ru.pscb.library.PSCBErrors +import ru.pscb.library.data.* +import ru.pscb.library.utils.SHA256Digest +import java.net.URI +import java.net.URL +import java.nio.charset.StandardCharsets + +/** + * Стандартная реализация интерфейса [[PSCBOnlineClient]]. + * Реализация потоко-безопасна, т.к. не хранит внутренних состояний. + * + * @author Antonov Ilia antilya@gmail.com + * @since 11/26/2020 + */ +class DefaultPSCBOnlineClient( + private val environment: BackendEnvironment, + private val marketPlaceId: Long, + private val signingKey: String +) : PSCBOnlineClient { + + // private val requestQueue = + private var serverUrl: URL = { + URI.create("https://${environment.server}/merchantApi/payShpa").toURL() + }() + + @Throws(InvalidPaymentData::class) + override fun makeRequestWithCardData(card: CardData, payment: Payment): RequestWrapper { + val request = RequestWrapper( + marketPlaceId, payment, card + ) + + // Fail-fast + try { + request.encodeToJsonString() + } catch (ex: JSONException) { + throw InvalidPaymentData(ex) + } + + return request + } + + override fun makeRequestWithTokenData( + paymentData: PaymentData, + payment: Payment + ): RequestWrapper { + val paymentInformation = paymentData.toJson() + val token = JSONObject(paymentInformation).getJSONObject("paymentMethodData").getJSONObject("tokenizationData").getString("token") + val flags = Base64.DEFAULT or Base64.NO_WRAP + + return RequestWrapper(marketPlaceId, payment, Base64.encodeToString(token.toByteArray(), flags)) + } + + override fun send(request: RequestWrapper, onComplete: APICompletionHandler): CancellableRequest { + // Calculate signature and get HTTP body at the same time + val httpBody = request.encodeToJsonString() + val signature = calculateSignature(httpBody) + + // Request params + val url = this.serverUrl + val req = Fuel + .post("$url") + .jsonBody(httpBody, StandardCharsets.UTF_8) + .header("Signature", signature) + + return req.response { _, _, result -> + val (bytes, error) = result + if (error != null) { + onComplete(BackendResponse.failure(error.exception)) + return@response + } + + if (bytes == null) { + onComplete(BackendResponse.failure(EmptyResponseBodyException())) + return@response + } + + try { + val response = Response.decodeFrom(bytes) + val success = BackendResponse.success(response) + + onComplete(success) + } catch (ex: JSONException) { + onComplete(BackendResponse.failure(ex)) + } + } + } + + override fun send(request: RequestWrapper, onComplete: PostResponseHandler): CancellableRequest { + // Construct a closure wrapper with predefined guards and checks + // send request using a more low-level construct above + return send(request) inner@ { backendResponse -> + // HTTP request succeeded? + if (backendResponse.status == RequestStatus.FAILURE) { + // println("Failed to execute request due to error", backendResponse.er) + + onComplete(PSCBErrors.cause(backendResponse.error), null) + return@inner + } + + // Backend returned valid response? + if (backendResponse.response == null) { + onComplete(PSCBErrors.noData(), null) + return@inner + } + + // Backend payment succeeded? + val response = backendResponse.response + if (response.status == ResponseStatus.FAILURE) { + // print("Unable to process payment") + + val responseError = response.error + val backendError = PSCBErrors.backend( + responseError?.code, + responseError?.description + ) + + // At this point we can pass response too + onComplete(backendError, response) + return@inner + } + + // Finally succeeds + onComplete(null, response) + } + } + + // ~ + // private: + + private fun calculateSignature(httpBody: String): String { + val composite = httpBody + signingKey + return SHA256Digest.stringOf(composite) + } + +} \ No newline at end of file diff --git a/library/src/main/java/library/client/PSCBAPI.kt b/library/src/main/java/library/client/PSCBAPI.kt new file mode 100644 index 0000000..926ce95 --- /dev/null +++ b/library/src/main/java/library/client/PSCBAPI.kt @@ -0,0 +1,222 @@ +package ru.pscb.library.client + +import android.app.Activity +import android.util.Log +import com.google.android.gms.common.api.ApiException +import com.google.android.gms.tasks.Task +import com.google.android.gms.wallet.* +import org.json.JSONArray +import org.json.JSONException +import org.json.JSONObject +import ru.pscb.library.data.RequestStatus +import ru.pscb.library.data.Response +import java.math.BigDecimal +import java.math.RoundingMode + +object PSCBAPI { + + private val merchantInfo: JSONObject + @Throws(JSONException::class) + get() = JSONObject().put("merchantName", "Example Merchant") + + @JvmStatic + fun makePaymentRequest(merchantId: String, amount: BigDecimal): PaymentDataRequest { + val paymentDataRequestJson: JSONObject + try { // todo builder for google request + val scaledAmount = amount.setScale(2, RoundingMode.HALF_UP) + paymentDataRequestJson = JSONObject(baseRequest.toString()).apply { + put("allowedPaymentMethods", JSONArray().put(cardPaymentMethod(merchantId))) + put("transactionInfo", getTransactionInfo(scaledAmount.toString())) + put("merchantInfo", merchantInfo) + +// put("shippingAddressRequired", true) +// put("shippingAddressParameters", shippingAddressParameters) + } + } catch (e: JSONException) { + throw e + } + + return PaymentDataRequest.fromJson(paymentDataRequestJson.toString()) + } + + fun ifCanMakePayments(paymentsClient: PaymentsClient, consumer: (Boolean) -> Unit): Task { + val isReadyToPayJson = isReadyToPayRequest() + val request = IsReadyToPayRequest.fromJson(isReadyToPayJson.toString()) + val task = paymentsClient.isReadyToPay(request) + + task.addOnCompleteListener { completed -> + try { + completed.getResult(ApiException::class.java)?.let(consumer) + } catch (exception: ApiException) { + Log.w("isReadyToPay failed", exception) + } + } + + return task + } + + @JvmStatic + fun createPaymentClient(activity: Activity, environment: Int): PaymentsClient { + val walletOptions = Wallet.WalletOptions.Builder() + .setEnvironment(environment) + .build() + + return Wallet.getPaymentsClient(activity, walletOptions) + } + + // ~ + // private: + + private const val PAYMENT_GATEWAY_TOKENIZATION_NAME = "pscbru" + + private val allowedCardNetworks = JSONArray(listOf("VISA", "MASTERCARD")) + private val allowedCardAuthMethods = JSONArray(listOf("PAN_ONLY", "CRYPTOGRAM_3DS")) + + + /** + * Create a Google Pay API base request object with properties used in all requests. + * + * @return Google Pay API base request object. + * @throws JSONException + */ + private val baseRequest = JSONObject().apply { + put("apiVersion", 2) + put("apiVersionMinor", 0) + } + + /** + * Describe your app's support for the CARD payment method. + * + * + * The provided properties are applicable to both an IsReadyToPayRequest and a + * PaymentDataRequest. + * + * @return A CARD PaymentMethod object describing accepted cards. + * @throws JSONException + * @see [PaymentMethod](https://developers.google.com/pay/api/android/reference/object.PaymentMethod) + */ + // Optionally, you can add billing address/phone number associated with a CARD payment method. + private fun baseCardPaymentMethod(): JSONObject { + return JSONObject().apply { + + val parameters = JSONObject().apply { + put("allowedAuthMethods", allowedCardAuthMethods) + put("allowedCardNetworks", allowedCardNetworks) + put("billingAddressRequired", false) + put("billingAddressParameters", JSONObject().apply { + put("format", "FULL") + }) + } + + put("type", "CARD") + put("parameters", parameters) + } + } + + /** + * An object describing accepted forms of payment by your app, used to determine a viewer's + * readiness to pay. + * + * @return API version and payment methods supported by the app. + * @see [IsReadyToPayRequest](https://developers.google.com/pay/api/android/reference/object.IsReadyToPayRequest) + */ + private fun isReadyToPayRequest(): JSONObject? { + return try { + val isReadyToPayRequest = JSONObject(baseRequest.toString()) + isReadyToPayRequest.put( + "allowedPaymentMethods", JSONArray().put(baseCardPaymentMethod())) + + isReadyToPayRequest + + } catch (e: JSONException) { + null + } + } + + /** + * Describe the expected returned payment data for the CARD payment method + * + * @return A CARD PaymentMethod describing accepted cards and optional fields. + * @throws JSONException + * @see [PaymentMethod](https://developers.google.com/pay/api/android/reference/object.PaymentMethod) + */ + private fun cardPaymentMethod(merchantId: String): JSONObject { + val cardPaymentMethod = baseCardPaymentMethod() + cardPaymentMethod.put("tokenizationSpecification", gatewayTokenizationSpecification(merchantId)) + + return cardPaymentMethod + } + + private fun gatewayTokenizationSpecification(merchantId: String): JSONObject { + return JSONObject().apply { + put("type", "PAYMENT_GATEWAY") + put("parameters", JSONObject(mapOf( + "gateway" to PAYMENT_GATEWAY_TOKENIZATION_NAME, + "gatewayMerchantId" to merchantId + ))) + } + } + + /** + * Provide Google Pay API with a payment amount, currency, and amount status. + * + * @return information about the requested payment. + * @throws JSONException + * @see [TransactionInfo](https://developers.google.com/pay/api/android/reference/object.TransactionInfo) + */ + @Throws(JSONException::class) + private fun getTransactionInfo(price: String, countryCode: String = "RU"): JSONObject { + return JSONObject().apply { + put("totalPrice", price) + put("totalPriceStatus", "FINAL") + put("countryCode", countryCode) + put("currencyCode", "RUB") + } + } + +} + +/** + * Среда ПСКБ-сервиса + */ +enum class BackendEnvironment(val server: String) { + SANDBOX("oosdemo.pscb.ru"), + PRODUCTION("oos.pscb.ru"); +} + +// ~ +// Backend response: + +/** + * Структура ответа от бэкенд сервиса ПСКБ-Онлайн + */ +data class BackendResponse( + // Represents request status + val status: RequestStatus, + // Hold error information if request is a `.failure` + val error: Throwable?, + // Holds response information if request is a `.success` + val response: Response? +) { + + companion object { + + @JvmStatic + fun failure(error: Throwable): BackendResponse = BackendResponse(RequestStatus.FAILURE, error, response = null) + + @JvmStatic + fun success(response: Response): BackendResponse = BackendResponse(RequestStatus.SUCCESS, null, response) + + } + +} + +/** + * Обработчик ответа ПСКБ-Онлайн + */ +typealias APICompletionHandler = (BackendResponse) -> Unit + +/** + * Обработчик ответа + */ +typealias PostResponseHandler = (Throwable?, Response?) -> Unit \ No newline at end of file diff --git a/library/src/main/java/library/client/PSCBOnlineClient.kt b/library/src/main/java/library/client/PSCBOnlineClient.kt new file mode 100644 index 0000000..4957f61 --- /dev/null +++ b/library/src/main/java/library/client/PSCBOnlineClient.kt @@ -0,0 +1,90 @@ +package ru.pscb.library.client + +import com.github.kittinunf.fuel.core.requests.CancellableRequest +import com.google.android.gms.wallet.PaymentData +import ru.pscb.library.data.CardData +import ru.pscb.library.data.Payment +import ru.pscb.library.data.RequestWrapper + +/** + * Описание протокола ПСКБ-Онлайн для Java. + * + * @author Antonov Ilia antilya@gmail.com + * @since 11/20/2020 + */ +interface PSCBOnlineClient { + + /** + * Creates instance of [[RequestWrapper]] for a backend to process from raw card data token. + *

+ * Example: + * ``` + * val card = CardData( + * pan = "409444400001234", + * expiryDate = YearMonth.of(2025, 12), + * cvCode = "000", + * cardholder = "JOHN DOE" + * ) + * + * val payment = Payment(amount = Decimal(1500), orderId = "XC-12345") + * val request = apiClient.createRequestWithCardData(card = card, payment = payment) + * + * // Send to backend: + * apiClient.send(request) { (response) in /code/ } + * ``` + * + * @param card an instance of card data [[CardData]]. + * @param payment an instance of [[Payment]]. + */ + fun makeRequestWithCardData(card: CardData, payment: Payment): RequestWrapper + + fun makeRequestWithTokenData(paymentData: PaymentData, payment: Payment): RequestWrapper + + /** + * Signs and sends compiled `RequestWrapper` to the backend server + * Fires [[APICompletionHandler]] once requests succeeds or fails + * + * @param request [[RequestWrapper]] created from `createRequestWithPayment(...)` + * @param onComplete [[APICompletionHandler]] a callback for when requests succeeds or fails + * @return [[CancellableRequest]] + */ + fun send(request: RequestWrapper, onComplete: APICompletionHandler): CancellableRequest + + /** + * Signs and sends compiled `RequestWrapper` to the backend server + * Fires `PostResponseHandler` once requests succeeds or fails. + * `PostResponseHandler` accepts two arguments: `Throwable?` and `Response?`. + * + * If requests succeeds and payment in desired state `Throwable?` will always be `null`. + * `Response?` presents on some errors and on all successes. + * + * This is an utility method to reduce boilerplate for necessary checks. + * + * @param request [[RequestWrapper]] created from `createRequestWithPayment(...)` + * @param onComplete [[APICompletionHandler]] a callback for when requests succeeds or fails + * @return [[CancellableRequest]] + */ + fun send(request: RequestWrapper, onComplete: PostResponseHandler): CancellableRequest + + // ~ + // companion: + + companion object { + + /** + * Создаёт экземпляр класса с настройками, направленные на тестовую среду `sandbox` + */ + @JvmStatic + fun sandbox(marketPlaceId: Long, signingKey: String) = DefaultPSCBOnlineClient( + BackendEnvironment.SANDBOX, marketPlaceId, signingKey + ) + + /** + * Создаёт экземпляр класса с настройками, направленные на продуктовую среду `production` + */ + @JvmStatic + fun production(marketPlaceId: Long, signingKey: String) = DefaultPSCBOnlineClient( + BackendEnvironment.PRODUCTION, marketPlaceId, signingKey + ) + } +} \ No newline at end of file diff --git a/library/src/main/java/library/data/CardData.kt b/library/src/main/java/library/data/CardData.kt new file mode 100644 index 0000000..f12bf8d --- /dev/null +++ b/library/src/main/java/library/data/CardData.kt @@ -0,0 +1,67 @@ +package ru.pscb.library.data + +import android.util.Base64 +import ru.pscb.library.utils.KeyHolder +import java.nio.charset.StandardCharsets +import javax.crypto.Cipher + + +/** + * Данные карты для оплаты. + * + * @author Antonov Ilia antilya@gmail.com + * @since 11/19/2020 + */ +data class CardData( + private val pan: String, + private val expiryMonth: Int, + private val expiryYear: Int, + private val cvCode: String, + private val cardholder: String? = null +) { + + // ~ + // init: + + init { + check(cvCode.isNotBlank() && cvCode.length == 3) { "[cvCode] must a be 3-digit string" } + } + + // ~ + // public: + + fun toCryptogram(): ByteArray { + val template = getCardTemplate() + val container = KeyHolder.getContainer() + + // Encrypt data + val cipher = Cipher.getInstance("RSA/ECB/PKCS1Padding") + cipher.init(Cipher.ENCRYPT_MODE, container.unsafeGetKey()) + + // Encode as base64 + val bytes = cipher.doFinal(template.toByteArray(StandardCharsets.UTF_8)) + val flags = Base64.DEFAULT or Base64.NO_WRAP + return Base64.encode(bytes, flags) + } + + // ~ + // private: + + private fun get2DigitExpiryYear(): String = "${expiryYear}".drop(2) + + private fun get2DigitExpiryMonth(): String = + if (expiryMonth < 10) "0${expiryMonth}" else "${expiryMonth}" + + private fun getCardTemplate(): String { + val month = get2DigitExpiryMonth() + val year = get2DigitExpiryYear() + var template = "${pan}|${month}|${year}|${cvCode}" + + if (cardholder != null) { + template += "|${cardholder}" + } + + return template + } + +} \ No newline at end of file diff --git a/library/src/main/java/library/data/CustomerData.kt b/library/src/main/java/library/data/CustomerData.kt new file mode 100644 index 0000000..af53e48 --- /dev/null +++ b/library/src/main/java/library/data/CustomerData.kt @@ -0,0 +1,17 @@ +package ru.pscb.library.data + +/** + * + * @author Antonov Ilia antilya@gmail.com + * @since 11/23/2020 + */ +class CustomerData( + val account: String, + val comment: String?, + val email: String?, + val phone: String? +) { + + constructor(account: String): this(account, null, null, null) + +} \ No newline at end of file diff --git a/library/src/main/java/library/data/Payment.kt b/library/src/main/java/library/data/Payment.kt new file mode 100644 index 0000000..e035b43 --- /dev/null +++ b/library/src/main/java/library/data/Payment.kt @@ -0,0 +1,60 @@ +package ru.pscb.library.data + +import org.json.JSONObject +import ru.pscb.library.serde.JsonEncodable +import java.math.BigDecimal +import java.math.RoundingMode + +/** + * Данные платежа для оплаты. + * Параметры коррелируют с параметрами запроса `message` в [[https://docs.pscb.ru/oos/api.html#api-magazina-sozdanie-platezha-zapros]] + * + * @author Antonov Ilia antilya@gmail.com + * @since 11/23/2020 + */ +data class Payment( + val amount: BigDecimal, + val orderId: String +) : JsonEncodable { + + // ~ + // properties: + + var showOrderId: String? = null + var details: String? = null + var customer: CustomerData? = null + + init { + require(amount > BigDecimal.ZERO) { "[amount] must be positive number greater than zero" } + require(orderId.isNotEmpty()) { "[orderId] must have a size and be distinguishable" } + + if (showOrderId == null) { + showOrderId = null + } + } + + constructor(amount: BigDecimal, orderId: String, showOrderId: String?, details: String?, customer: CustomerData?) + : this(amount, orderId) { + this.showOrderId = showOrderId + this.details = details + this.customer = customer + } + + override fun encodeToJson(): JSONObject { + return JSONObject().apply { + put("amount", amount.setScale(2, RoundingMode.HALF_UP)) + put("orderId", orderId) + put("showOrderId", showOrderId) + put("details", details) + put("paymentMethod", "shpa") + + if (customer != null) { + put("customerAccount", customer?.account) + put("customerComment", customer?.comment) + put("customerEmail", customer?.email) + put("customerPhone", customer?.phone) + } + } + } + +} \ No newline at end of file diff --git a/library/src/main/java/library/data/RequestWrapper.kt b/library/src/main/java/library/data/RequestWrapper.kt new file mode 100644 index 0000000..2e4e5d6 --- /dev/null +++ b/library/src/main/java/library/data/RequestWrapper.kt @@ -0,0 +1,30 @@ +package ru.pscb.library.data + +import org.json.JSONObject +import ru.pscb.library.serde.JsonEncodable +import java.nio.charset.StandardCharsets + +/** + * Конечный токенезированный объект для отправки на бэкенд ПСКБ-Онлайн + * + * @author Antonov Ilia antilya@gmail.com + * @since 11/25/2020 + */ +data class RequestWrapper( + val marketPlaceId: Long, + val payment: Payment, + val tokenizedData: String +) : JsonEncodable { + + constructor(marketPlaceId: Long, payment: Payment, cardData: CardData): + this(marketPlaceId, payment, String(cardData.toCryptogram(), StandardCharsets.UTF_8)) + + override fun encodeToJson(): JSONObject { + return JSONObject().apply { + put("marketPlace", marketPlaceId) + put("payment", payment.encodeToJson()) + put("cardData", tokenizedData) + } + } + +} \ No newline at end of file diff --git a/library/src/main/java/library/data/Response.kt b/library/src/main/java/library/data/Response.kt new file mode 100644 index 0000000..6da40f3 --- /dev/null +++ b/library/src/main/java/library/data/Response.kt @@ -0,0 +1,140 @@ +package ru.pscb.library.data + +import org.json.JSONObject +import ru.pscb.library.serde.JsonDecodable +import java.math.BigDecimal + +/** + * + * @author Antonov Ilia antilya@gmail.com + * @since 11/25/2020 + */ + +enum class ResponseStatus { + SUCCESS, + FAILURE +} + +// ~ +// Response Error: + +data class ResponseError(val code: String, val description: String?) { + companion object : JsonDecodable { + override fun decodeFrom(json: JSONObject): ResponseError { + val code = json.getString("errorCode") + val description = + if (json.has("errorDescription")) json.getString("errorDescription") + else "N/A" + + return ResponseError(code, description) + } + } +} + +// ~ +// OOS Response: + +class Response( + val status: ResponseStatus, + val requestId: String, + val error: ResponseError? = null, + val payment: ResponsePayment? = null, + val description: String? = null +) { + + companion object : JsonDecodable { + + @JvmStatic + override fun decodeFrom(json: JSONObject): Response { + val status = if (json.getString("status") == "STATUS_SUCCESS") ResponseStatus.SUCCESS else ResponseStatus.FAILURE + val id = json.getString("requestId") + + var error: ResponseError? = null + if (json.has("errorCode")) { + error = ResponseError.decodeFrom(json) + } + + var payment: ResponsePayment? = null + if (json.has("payment")) { + payment = ResponsePayment.decodeFrom(json.getJSONObject("payment")) + } + + var description: String? = null + if (json.has("description")) { + description = json.getString("description") + } + + return Response( + status, id, error, payment, description + ) + } + + } + + override fun toString(): String { + return "Response(status=$status, requestId='$requestId', error=$error, payment=$payment, description=$description)" + } + + +} + +enum class RequestStatus { + SUCCESS, FAILURE +} + +// ~ +// Response payment: + +data class ResponsePayment( + val orderId: String, + val showOrderId: String, + val paymentId: String, + val amount: BigDecimal, + val state: PaymentState, + val marketPlace: Long, + val stateDate: String +) { + + companion object : JsonDecodable { + override fun decodeFrom(json: JSONObject): ResponsePayment { + return ResponsePayment( + json.getString("orderId"), + json.getString("showOrderId"), + json.getString("paymentId"), + BigDecimal(json.getString("amount")), + PaymentState.decodeFrom(json), + json.getLong("marketPlace"), + json.getString("stateDate") + ) + } + } + + override fun toString(): String { + return "ResponsePayment(orderId='$orderId', paymentId='$paymentId', state=$state, stateDate=$stateDate)" + } + +} + +enum class PaymentState(protected val string: String) { + SENT("sent"), + NEW("new"), + END("end"), + REFUNDED("ref"), + HOLD("hold"), + EXPIRED("exp"), + CANCELED("canceled"), + ERROR("error"), + REJECTED("rej"), + UNDEFINED("undef"); + + companion object : JsonDecodable { + override fun decodeFrom(json: JSONObject): PaymentState { + val stateString = json.getString("state") + val state = values().find { it.string == stateString } + + return state ?: UNDEFINED + } + + } +} + diff --git a/library/src/main/java/library/serde/Codable.kt b/library/src/main/java/library/serde/Codable.kt new file mode 100644 index 0000000..723f710 --- /dev/null +++ b/library/src/main/java/library/serde/Codable.kt @@ -0,0 +1,30 @@ +@file:Suppress("unused") + +package ru.pscb.library.serde + +import org.json.JSONObject +import java.nio.charset.StandardCharsets + +/** + * + * @author Antonov Ilia antilya@gmail.com + * @since 11/25/2020 + */ +interface JsonEncodable { + + fun encodeToJsonString() : String = encodeToJson().toString() + + fun encodeToJson(): JSONObject + +} + +interface JsonDecodable { + + // This is remarkable in a sense that we're using String instead of bytes as a main entry point + fun decodeFrom(bytes: ByteArray) : T = decodeFrom(String(bytes, StandardCharsets.UTF_8)) + + fun decodeFrom(json: JSONObject) : T + + fun decodeFrom(string: String) : T = decodeFrom(JSONObject(string)) + +} \ No newline at end of file diff --git a/library/src/main/java/library/utils/KeyHolder.kt b/library/src/main/java/library/utils/KeyHolder.kt new file mode 100644 index 0000000..9dd4d4e --- /dev/null +++ b/library/src/main/java/library/utils/KeyHolder.kt @@ -0,0 +1,103 @@ +package ru.pscb.library.utils + +import android.util.Base64 +import java.io.IOException +import java.nio.charset.StandardCharsets +import java.security.GeneralSecurityException +import java.security.KeyFactory +import java.security.PublicKey +import java.security.spec.X509EncodedKeySpec + +/** + * + * @author Antonov Ilia antilya@gmail.com + * @since 11/19/2020 + */ +object KeyHolder { + + private val publicKey: PublicKeyContainer = PublicKeyContainer() + + init { + publicKey.tryInitializeKey() + } + + fun getContainer(): PublicKeyContainer = publicKey + +} + +enum class KeyContainerState { + UNINITIALIZED, + LOADED, + CORRUPTED +} + +/** + * A wrapper class so not to "explode" during app initialization because of library internal ꟻuck-ups. + */ +class PublicKeyContainer { + + // ~ + // internally managed fields: + + private var publicKey: PublicKey? = null + private var state: KeyContainerState = KeyContainerState.UNINITIALIZED + + // ~ + // public: + + fun isLoaded(): Boolean = state == KeyContainerState.LOADED + + fun getState() = state + + fun unsafeGetKey(): PublicKey { + check(isLoaded()) { "Public key could not be loaded due to internal state [$state]" } + + return publicKey!! + } + + fun tryInitializeKey() { + try { + val byteArray = PUBLIC_KEY.toByteArray(charset = StandardCharsets.UTF_8) + val publicKey = readPublicKey(byteArray) + + this.publicKey = publicKey + this.state = KeyContainerState.LOADED + } catch (sec: GeneralSecurityException) { + // Both NoSuchAlgorithmException & InvalidKeySpecException + state = KeyContainerState.CORRUPTED + } + } + + // ~ + // private: + + private fun readResource(resourcePath: String): ByteArray { + val stream = Thread.currentThread().contextClassLoader!!.getResourceAsStream(resourcePath) + check(stream != null) { "Expected public key to be supplied but it wasn't found in the resources" } + + try { + return stream.readBytes() + } finally { + try { stream.close() } catch (ignore: IOException) { /* No-op */ } + } + } + + private fun readPublicKey(byteArray: ByteArray): PublicKey { + val base64 = Base64.decode(byteArray, Base64.DEFAULT) + val factory = KeyFactory.getInstance(KEY_ALGORITHM) + val keySpec = X509EncodedKeySpec(base64) + + return factory.generatePublic(keySpec) + } + + // ~ + // companion: + + companion object { + private const val KEY_ALGORITHM = "RSA" + private const val PUBLIC_KEY = "MFwwDQYJKoZIhvcNAQEBBQADSwAwSAJBAPQsWyHaynwoc2tuMbengf1SFase9tPnwtPh4o1tR+94xsWztADdhhUaUBk/68ipaoZE8uSnM9UgdEPmOotFXyUCAwEAAQ==" + } + + +} + diff --git a/library/src/main/java/library/utils/SHA256Digest.kt b/library/src/main/java/library/utils/SHA256Digest.kt new file mode 100644 index 0000000..720006f --- /dev/null +++ b/library/src/main/java/library/utils/SHA256Digest.kt @@ -0,0 +1,29 @@ +package ru.pscb.library.utils + +import java.nio.charset.StandardCharsets +import java.security.MessageDigest + +/** + * TODO Document me + * + * @author Antonov Ilia antilya@gmail.com + * @since 11/30/2020 + */ +object SHA256Digest { + + private val ALGORITHM = "SHA-256" + + @JvmStatic + fun stringOf(string: String): String { + val sha256 = MessageDigest.getInstance(ALGORITHM) + val bytes = string.toByteArray(StandardCharsets.UTF_8) + val digest = sha256.digest(bytes) + + return digest.toHexString() + } + + fun ByteArray.toHexString() = joinToString("") { + "%02x".format(it) + } + +} \ No newline at end of file diff --git a/library/src/main/resources/pubkey b/library/src/main/resources/pubkey new file mode 100644 index 0000000..dca85d4 --- /dev/null +++ b/library/src/main/resources/pubkey @@ -0,0 +1 @@ +MFwwDQYJKoZIhvcNAQEBBQADSwAwSAJBAPQsWyHaynwoc2tuMbengf1SFase9tPnwtPh4o1tR+94xsWztADdhhUaUBk/68ipaoZE8uSnM9UgdEPmOotFXyUCAwEAAQ== \ No newline at end of file diff --git a/library/src/test/java/android/util/Base64.java b/library/src/test/java/android/util/Base64.java new file mode 100644 index 0000000..7e6a05a --- /dev/null +++ b/library/src/test/java/android/util/Base64.java @@ -0,0 +1,19 @@ +package android.util; + +import java.nio.charset.StandardCharsets; + +public class Base64 { + + public static byte[] decode(String string, int flags) { + return decode(string.getBytes(), flags); + } + + public static byte[] decode(byte[] array, int flags) { + return java.util.Base64.getDecoder().decode(array); + } + + public static byte[] encode(byte[] array, int flags) { + return java.util.Base64.getEncoder().encode(array); + } + +} diff --git a/library/src/test/java/library/data/CardDataTest.kt b/library/src/test/java/library/data/CardDataTest.kt new file mode 100644 index 0000000..a9a0090 --- /dev/null +++ b/library/src/test/java/library/data/CardDataTest.kt @@ -0,0 +1,26 @@ +package library.data + +import org.junit.Assert.* +import org.junit.Test +import ru.pscb.library.data.CardData + +class CardDataTest { + + @Test + fun cryptogram_builds_successfully() { + // given: + val card = CardData( + "4924000010002000", 2022, 12, "123", "DUMMY" + ) + + // when: + val crypto = card.toCryptogram() + + // then: + // noExceptionThrown() + + // and: + assertTrue(crypto.isNotEmpty()) + } + +} \ No newline at end of file diff --git a/library/src/test/java/ru/pscb/acquiring/ExampleUnitTest.kt b/library/src/test/java/ru/pscb/acquiring/ExampleUnitTest.kt new file mode 100644 index 0000000..fa6f14b --- /dev/null +++ b/library/src/test/java/ru/pscb/acquiring/ExampleUnitTest.kt @@ -0,0 +1,19 @@ +package ru.pscb.acquiring + +import org.junit.Test + +import org.junit.Assert.* + +/** + * Example local unit test, which will execute on the development machine (host). + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +class ExampleUnitTest { + + @Test + fun addition_isCorrect() { + assertEquals(4, 2 + 2) + } + +} \ No newline at end of file diff --git a/sample/.gitignore b/sample/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/sample/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/sample/build.gradle b/sample/build.gradle new file mode 100644 index 0000000..f65081d --- /dev/null +++ b/sample/build.gradle @@ -0,0 +1,51 @@ +plugins { + id 'com.android.application' + id 'kotlin-android' + id "kotlin-android-extensions" +} + +android { + compileSdkVersion 30 + buildToolsVersion "30.0.2" + + defaultConfig { + applicationId "ru.pscb.acquiring" + minSdkVersion 23 + targetSdkVersion 30 + versionCode 1 + versionName "1.0" + + 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 + } + kotlinOptions { + jvmTarget = '1.8' + } +} + +dependencies { + implementation project(':library') + implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" + implementation 'androidx.core:core-ktx:1.3.2' + implementation 'androidx.appcompat:appcompat:1.2.0' + implementation 'com.google.android.material:material:1.2.1' + implementation 'androidx.constraintlayout:constraintlayout:2.0.4' + + implementation "com.google.android.gms:play-services-wallet:16.0.1" + // Library stuff: + implementation "com.github.kittinunf.fuel:fuel:2.3.0" + + testImplementation 'junit:junit:4.+' + androidTestImplementation 'androidx.test.ext:junit:1.1.2' + androidTestImplementation 'androidx.test.espresso:espresso-core:3.3.0' +} \ No newline at end of file diff --git a/sample/proguard-rules.pro b/sample/proguard-rules.pro new file mode 100644 index 0000000..481bb43 --- /dev/null +++ b/sample/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/sample/src/androidTest/java/ru/pscb/acquiring/ExampleInstrumentedTest.kt b/sample/src/androidTest/java/ru/pscb/acquiring/ExampleInstrumentedTest.kt new file mode 100644 index 0000000..f248510 --- /dev/null +++ b/sample/src/androidTest/java/ru/pscb/acquiring/ExampleInstrumentedTest.kt @@ -0,0 +1,24 @@ +package ru.pscb.acquiring + +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.ext.junit.runners.AndroidJUnit4 + +import org.junit.Test +import org.junit.runner.RunWith + +import org.junit.Assert.* + +/** + * Instrumented test, which will execute on an Android device. + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +@RunWith(AndroidJUnit4::class) +class ExampleInstrumentedTest { + @Test + fun useAppContext() { + // Context of the app under test. + val appContext = InstrumentationRegistry.getInstrumentation().targetContext + assertEquals("ru.pscb.acquiring", appContext.packageName) + } +} \ No newline at end of file diff --git a/sample/src/main/AndroidManifest.xml b/sample/src/main/AndroidManifest.xml new file mode 100644 index 0000000..72a9db8 --- /dev/null +++ b/sample/src/main/AndroidManifest.xml @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/sample/src/main/java/ru/pscb/acquiring/MainActivity.kt b/sample/src/main/java/ru/pscb/acquiring/MainActivity.kt new file mode 100644 index 0000000..3ed0a8b --- /dev/null +++ b/sample/src/main/java/ru/pscb/acquiring/MainActivity.kt @@ -0,0 +1,178 @@ +package ru.pscb.acquiring + +import android.app.Activity +import android.content.Context +import android.content.Intent +import androidx.appcompat.app.AppCompatActivity +import android.os.Bundle +import android.util.Log +import android.view.View +import android.widget.Toast +import com.google.android.gms.wallet.* +import ru.pscb.library.client.BackendEnvironment +import ru.pscb.library.client.DefaultPSCBOnlineClient +import ru.pscb.library.client.PSCBOnlineClient +import kotlinx.android.synthetic.main.activity_main.* +import org.json.JSONException +import org.json.JSONObject +import ru.pscb.library.client.PSCBAPI +import ru.pscb.library.data.Payment +import java.math.BigDecimal +import java.util.* + +class MainActivity : AppCompatActivity() { + + /** + * Arbitrarily-picked constant integer you define to track a request for payment data activity. + * + * @value #LOAD_PAYMENT_DATA_REQUEST_CODE + */ + private val LOAD_PAYMENT_DATA_REQUEST_CODE = 991 + + private val price = BigDecimal("155.24") + + lateinit var paymentsClient: PaymentsClient + lateinit var apiClient: PSCBOnlineClient + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(R.layout.activity_main) + + paymentsClient = PSCBAPI.createPaymentClient(this, WalletConstants.ENVIRONMENT_TEST) + apiClient = DefaultPSCBOnlineClient( + environment = BackendEnvironment.SANDBOX, // или PRODUCTION + marketPlaceId = 288747332, /* <-- Your MarketPlace ID */ + signingKey = "111111" + ) + + PSCBAPI.ifCanMakePayments(paymentsClient, ::setGooglePayAvailable) + + googlePayButton.setOnClickListener { requestPayment() } + } + + private fun requestPayment() { + // Disables the button to prevent multiple clicks. + googlePayButton.isClickable = false + + // This price is not displayed to the user. + try { + val request = PSCBAPI.makePaymentRequest("BCR2DN6TRP365LR2", price) + + // Since loadPaymentData may show the UI asking the user to select a payment method, we use + // AutoResolveHelper to wait for the user interacting with it. Once completed, + // onActivityResult will be called with the result. + AutoResolveHelper.resolveTask( + paymentsClient.loadPaymentData(request), this, LOAD_PAYMENT_DATA_REQUEST_CODE + ) + + } catch (ex: JSONException) { + Log.e("requestPayment", "Could not gather payment request data") + } + } + + /** + * Handle a resolved activity from the Google Pay payment sheet. + * + * @param requestCode Request code originally supplied to AutoResolveHelper in requestPayment(). + * @param resultCode Result code returned by the Google Pay API. + * @param data Intent from the Google Pay API containing payment or error data. + * @see [Getting a result + * from an Activity](https://developer.android.com/training/basics/intents/result) + */ + public override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + when (requestCode) { + // value passed in AutoResolveHelper + LOAD_PAYMENT_DATA_REQUEST_CODE -> { + when (resultCode) { + Activity.RESULT_OK -> + data?.let { intent -> + PaymentData.getFromIntent(intent)?.let(::handleAuthorization) + } + Activity.RESULT_CANCELED -> { + // Nothing to do here normally - the user simply cancelled without selecting a + // payment method. + } + + AutoResolveHelper.RESULT_ERROR -> { + AutoResolveHelper.getStatusFromIntent(data)?.let { + handleError(it.statusCode) + } + } + } + // Re-enables the Google Pay payment button. + googlePayButton.isClickable = true + } + } + + super.onActivityResult(requestCode, resultCode, data) + } + + /** + * PaymentData response object contains the payment information, as well as any additional + * requested information, such as billing and shipping address. + * + * @param paymentData A response object returned by Google after a payer approves payment. + * @see [Payment + * Data](https://developers.google.com/pay/api/android/reference/object.PaymentData) + */ + private fun handleAuthorization(paymentData: PaymentData) { + val paymentInformation = paymentData.toJson() ?: return + + try { + // Token will be null if PaymentDataRequest was not constructed using fromJson(String). + val paymentMethodData = JSONObject(paymentInformation).getJSONObject("paymentMethodData") + + // Logging token string. + Log.d("GooglePaymentToken", paymentMethodData + .getJSONObject("tokenizationData") + .getString("token")) + + // PSCB Online API call + val payment = Payment(price, Date().toString()) + val request = apiClient.makeRequestWithTokenData(paymentData, payment) + val context: Context = this + + apiClient.send(request) { throwable, response -> + if (throwable != null) { + Toast.makeText(context, "Rawr", Toast.LENGTH_SHORT).show() + Log.d("HandlePaymentSuccess", "Failed to init a payment", throwable) + } + + if (response != null) { + Toast.makeText(context, "Meow", Toast.LENGTH_LONG).show() + Log.d("HandlePaymentSuccess", "Backend response $response") + } + } + + } catch (e: JSONException) { + Log.e("handlePaymentSuccess", "Error: " + e.toString()) + } + + } + + /** + * At this stage, the user has already seen a popup informing them an error occurred. Normally, + * only logging is required. + * + * @param statusCode will hold the value of any constant from CommonStatusCode or one of the + * WalletConstants.ERROR_CODE_* constants. + * @see [ + * Wallet Constants Library](https://developers.google.com/android/reference/com/google/android/gms/wallet/WalletConstants.constant-summary) + */ + private fun handleError(statusCode: Int) { + Log.w("loadPaymentData failed", String.format("Error code: %d", statusCode)) + } + + private fun setGooglePayAvailable(available: Boolean) { + if (available) { + googlePayButton.visibility = View.VISIBLE + } else { + Toast.makeText( + this, + "Unfortunately, Google Pay is not available", + Toast.LENGTH_LONG + ).show() + } + } + +} \ No newline at end of file diff --git a/sample/src/main/res/drawable-v24/ic_launcher_foreground.xml b/sample/src/main/res/drawable-v24/ic_launcher_foreground.xml new file mode 100644 index 0000000..2b068d1 --- /dev/null +++ b/sample/src/main/res/drawable-v24/ic_launcher_foreground.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/sample/src/main/res/drawable/buy_with_googlepay_button_content.xml b/sample/src/main/res/drawable/buy_with_googlepay_button_content.xml new file mode 100644 index 0000000..91cad57 --- /dev/null +++ b/sample/src/main/res/drawable/buy_with_googlepay_button_content.xml @@ -0,0 +1,54 @@ + + + + + + + + + + diff --git a/sample/src/main/res/drawable/googlepay_button_background_image.9.png b/sample/src/main/res/drawable/googlepay_button_background_image.9.png new file mode 100644 index 0000000..7091d81 Binary files /dev/null and b/sample/src/main/res/drawable/googlepay_button_background_image.9.png differ diff --git a/sample/src/main/res/drawable/googlepay_button_no_shadow_background.xml b/sample/src/main/res/drawable/googlepay_button_no_shadow_background.xml new file mode 100644 index 0000000..f2fa4f7 --- /dev/null +++ b/sample/src/main/res/drawable/googlepay_button_no_shadow_background.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/sample/src/main/res/drawable/googlepay_button_no_shadow_background_image.9.png b/sample/src/main/res/drawable/googlepay_button_no_shadow_background_image.9.png new file mode 100644 index 0000000..4f9bc6b Binary files /dev/null and b/sample/src/main/res/drawable/googlepay_button_no_shadow_background_image.9.png differ diff --git a/sample/src/main/res/drawable/googlepay_button_overlay.xml b/sample/src/main/res/drawable/googlepay_button_overlay.xml new file mode 100644 index 0000000..ac49bbd --- /dev/null +++ b/sample/src/main/res/drawable/googlepay_button_overlay.xml @@ -0,0 +1,5 @@ + + + + \ No newline at end of file diff --git a/sample/src/main/res/drawable/ic_launcher_background.xml b/sample/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 0000000..07d5da9 --- /dev/null +++ b/sample/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,170 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/sample/src/main/res/drawable/ic_launcher_foreground.xml b/sample/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..2b068d1 --- /dev/null +++ b/sample/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/sample/src/main/res/layout/activity_main.xml b/sample/src/main/res/layout/activity_main.xml new file mode 100644 index 0000000..92bd45a --- /dev/null +++ b/sample/src/main/res/layout/activity_main.xml @@ -0,0 +1,78 @@ + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/sample/src/main/res/layout/buy_with_googlepay_button.xml b/sample/src/main/res/layout/buy_with_googlepay_button.xml new file mode 100644 index 0000000..2553181 --- /dev/null +++ b/sample/src/main/res/layout/buy_with_googlepay_button.xml @@ -0,0 +1,31 @@ + + + + + + + diff --git a/sample/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/sample/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..eca70cf --- /dev/null +++ b/sample/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/sample/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/sample/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..eca70cf --- /dev/null +++ b/sample/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/sample/src/main/res/mipmap-hdpi/ic_launcher.png b/sample/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..a571e60 Binary files /dev/null and b/sample/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/sample/src/main/res/mipmap-hdpi/ic_launcher_round.png b/sample/src/main/res/mipmap-hdpi/ic_launcher_round.png new file mode 100644 index 0000000..61da551 Binary files /dev/null and b/sample/src/main/res/mipmap-hdpi/ic_launcher_round.png differ diff --git a/sample/src/main/res/mipmap-mdpi/ic_launcher.png b/sample/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..c41dd28 Binary files /dev/null and b/sample/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/sample/src/main/res/mipmap-mdpi/ic_launcher_round.png b/sample/src/main/res/mipmap-mdpi/ic_launcher_round.png new file mode 100644 index 0000000..db5080a Binary files /dev/null and b/sample/src/main/res/mipmap-mdpi/ic_launcher_round.png differ diff --git a/sample/src/main/res/mipmap-xhdpi/ic_launcher.png b/sample/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..6dba46d Binary files /dev/null and b/sample/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/sample/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/sample/src/main/res/mipmap-xhdpi/ic_launcher_round.png new file mode 100644 index 0000000..da31a87 Binary files /dev/null and b/sample/src/main/res/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/sample/src/main/res/mipmap-xxhdpi/ic_launcher.png b/sample/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..15ac681 Binary files /dev/null and b/sample/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/sample/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/sample/src/main/res/mipmap-xxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..b216f2d Binary files /dev/null and b/sample/src/main/res/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/sample/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/sample/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..f25a419 Binary files /dev/null and b/sample/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/sample/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/sample/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..e96783c Binary files /dev/null and b/sample/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/sample/src/main/res/values-night/themes.xml b/sample/src/main/res/values-night/themes.xml new file mode 100644 index 0000000..da94682 --- /dev/null +++ b/sample/src/main/res/values-night/themes.xml @@ -0,0 +1,16 @@ + + + + \ No newline at end of file diff --git a/sample/src/main/res/values/colors.xml b/sample/src/main/res/values/colors.xml new file mode 100644 index 0000000..f8c6127 --- /dev/null +++ b/sample/src/main/res/values/colors.xml @@ -0,0 +1,10 @@ + + + #FFBB86FC + #FF6200EE + #FF3700B3 + #FF03DAC5 + #FF018786 + #FF000000 + #FFFFFFFF + \ No newline at end of file diff --git a/sample/src/main/res/values/dimens.xml b/sample/src/main/res/values/dimens.xml new file mode 100644 index 0000000..4f3e482 --- /dev/null +++ b/sample/src/main/res/values/dimens.xml @@ -0,0 +1,28 @@ + + + + + + 5dp + 10dp + 30dp + + 300dp + 48sp + 200dp + + diff --git a/sample/src/main/res/values/strings.xml b/sample/src/main/res/values/strings.xml new file mode 100644 index 0000000..57773b4 --- /dev/null +++ b/sample/src/main/res/values/strings.xml @@ -0,0 +1,4 @@ + + Acquiring Sample + Buy with Google Pay + \ No newline at end of file diff --git a/sample/src/main/res/values/themes.xml b/sample/src/main/res/values/themes.xml new file mode 100644 index 0000000..e06ca62 --- /dev/null +++ b/sample/src/main/res/values/themes.xml @@ -0,0 +1,16 @@ + + + + \ No newline at end of file diff --git a/sample/src/test/java/ru/pscb/acquiring/ExampleUnitTest.kt b/sample/src/test/java/ru/pscb/acquiring/ExampleUnitTest.kt new file mode 100644 index 0000000..d5246a0 --- /dev/null +++ b/sample/src/test/java/ru/pscb/acquiring/ExampleUnitTest.kt @@ -0,0 +1,17 @@ +package ru.pscb.acquiring + +import org.junit.Test + +import org.junit.Assert.* + +/** + * Example local unit test, which will execute on the development machine (host). + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +class ExampleUnitTest { + @Test + fun addition_isCorrect() { + assertEquals(4, 2 + 2) + } +} \ No newline at end of file diff --git a/settings.gradle b/settings.gradle new file mode 100644 index 0000000..dd4e467 --- /dev/null +++ b/settings.gradle @@ -0,0 +1,3 @@ +include ':library' +include ':sample' +rootProject.name = "Acquiring Lib" \ No newline at end of file