init
|
@ -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
|
|
@ -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
|
||||
<repositories>
|
||||
<repository>
|
||||
<snapshots>
|
||||
<enabled>false</enabled>
|
||||
</snapshots>
|
||||
<name>pscb</name>
|
||||
<url>https://nxs.pscb.ru/repository/pscb-releases/</url>
|
||||
</repository>
|
||||
</repositories>
|
||||
```
|
||||
|
||||
Добавить зависимость:
|
||||
```xml
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>ru.pscb</groupId>
|
||||
<artifactId>online-java</artifactId>
|
||||
<version>1.0.0</version>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
```
|
||||
|
||||
## Интеграция
|
||||
|
||||
#### Оплата картами
|
||||
|
||||
* Создайте экземпляр `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("<Your Google Merchant ID>", 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()
|
||||
}
|
||||
```
|
|
@ -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
|
||||
}
|
|
@ -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
|
|
@ -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
|
|
@ -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" "$@"
|
|
@ -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
|
|
@ -0,0 +1 @@
|
|||
/build
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
|
@ -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)
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,5 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
package="ru.pscb.library">
|
||||
|
||||
</manifest>
|
|
@ -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 ?: "")
|
||||
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
|
||||
}
|
|
@ -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<Boolean> {
|
||||
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
|
|
@ -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.
|
||||
* <p>
|
||||
* 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
|
||||
)
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
|
||||
}
|
|
@ -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)
|
||||
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -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<ResponseError> {
|
||||
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<Response> {
|
||||
|
||||
@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<ResponsePayment> {
|
||||
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<PaymentState> {
|
||||
override fun decodeFrom(json: JSONObject): PaymentState {
|
||||
val stateString = json.getString("state")
|
||||
val state = values().find { it.string == stateString }
|
||||
|
||||
return state ?: UNDEFINED
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
|
@ -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<out T> {
|
||||
|
||||
// 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))
|
||||
|
||||
}
|
|
@ -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=="
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
MFwwDQYJKoZIhvcNAQEBBQADSwAwSAJBAPQsWyHaynwoc2tuMbengf1SFase9tPnwtPh4o1tR+94xsWztADdhhUaUBk/68ipaoZE8uSnM9UgdEPmOotFXyUCAwEAAQ==
|
|
@ -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);
|
||||
}
|
||||
|
||||
}
|
|
@ -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())
|
||||
}
|
||||
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
/build
|
|
@ -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'
|
||||
}
|
|
@ -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
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,24 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
package="ru.pscb.acquiring">
|
||||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:label="@string/app_name"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/Theme.AcquiringLib">
|
||||
<activity android:name=".MainActivity">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<meta-data android:name="com.google.android.gms.wallet.api.enabled" android:value="true" />
|
||||
|
||||
</application>
|
||||
|
||||
</manifest>
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,30 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:aapt="http://schemas.android.com/aapt"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<path android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z">
|
||||
<aapt:attr name="android:fillColor">
|
||||
<gradient
|
||||
android:endX="85.84757"
|
||||
android:endY="92.4963"
|
||||
android:startX="42.9492"
|
||||
android:startY="49.59793"
|
||||
android:type="linear">
|
||||
<item
|
||||
android:color="#44000000"
|
||||
android:offset="0.0" />
|
||||
<item
|
||||
android:color="#00000000"
|
||||
android:offset="1.0" />
|
||||
</gradient>
|
||||
</aapt:attr>
|
||||
</path>
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:fillType="nonZero"
|
||||
android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z"
|
||||
android:strokeWidth="1"
|
||||
android:strokeColor="#00000000" />
|
||||
</vector>
|
|
@ -0,0 +1,54 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="103dp"
|
||||
android:height="17dp"
|
||||
android:viewportWidth="103.0"
|
||||
android:viewportHeight="17.0">
|
||||
<path
|
||||
android:pathData="M0.148,2.976L3.914,2.976C4.446,2.976 4.938,3.093 5.391,3.326C5.844,3.559 6.205,3.881 6.476,4.292C6.747,4.703 6.882,5.155 6.882,5.65C6.882,6.145 6.758,6.574 6.511,6.938C6.264,7.302 5.939,7.577 5.538,7.764L5.538,7.848C6.042,8.025 6.45,8.319 6.763,8.73C7.076,9.141 7.232,9.621 7.232,10.172C7.232,10.723 7.09,11.213 6.805,11.642C6.52,12.071 6.138,12.405 5.657,12.643C5.176,12.881 4.651,13 4.082,13L0.148,13L0.148,2.976ZM3.844,7.176C4.292,7.176 4.654,7.036 4.929,6.756C5.204,6.476 5.342,6.154 5.342,5.79C5.342,5.426 5.209,5.106 4.943,4.831C4.677,4.556 4.329,4.418 3.9,4.418L1.716,4.418L1.716,7.176L3.844,7.176ZM4.082,11.544C4.558,11.544 4.938,11.395 5.223,11.096C5.508,10.797 5.65,10.452 5.65,10.06C5.65,9.659 5.503,9.311 5.209,9.017C4.915,8.723 4.521,8.576 4.026,8.576L1.716,8.576L1.716,11.544L4.082,11.544ZM9.461,12.447C9.008,11.929 8.782,11.208 8.782,10.284L8.782,5.86L10.322,5.86L10.322,10.074C10.322,10.653 10.46,11.087 10.735,11.376C11.01,11.665 11.372,11.81 11.82,11.81C12.184,11.81 12.506,11.714 12.786,11.523C13.066,11.332 13.281,11.077 13.43,10.76C13.579,10.443 13.654,10.102 13.654,9.738L13.654,5.86L15.194,5.86L15.194,13L13.738,13L13.738,12.076L13.654,12.076C13.458,12.412 13.155,12.687 12.744,12.902C12.333,13.117 11.899,13.224 11.442,13.224C10.574,13.224 9.914,12.965 9.461,12.447ZM19.32,12.608L16.352,5.86L18.074,5.86L20.09,10.718L20.146,10.718L22.106,5.86L23.8,5.86L19.39,16.024L17.766,16.024L19.32,12.608ZM27.586,5.86L29.252,5.86L30.694,10.97L30.75,10.97L32.36,5.86L33.942,5.86L35.538,10.97L35.594,10.97L37.036,5.86L38.674,5.86L36.392,13L34.768,13L33.13,7.876L33.088,7.876L31.464,13L29.868,13L27.586,5.86ZM39.965,4.523C39.764,4.322 39.664,4.077 39.664,3.788C39.664,3.499 39.764,3.254 39.965,3.053C40.166,2.852 40.411,2.752 40.7,2.752C40.989,2.752 41.234,2.852 41.435,3.053C41.636,3.254 41.736,3.499 41.736,3.788C41.736,4.077 41.636,4.322 41.435,4.523C41.234,4.724 40.989,4.824 40.7,4.824C40.411,4.824 40.166,4.724 39.965,4.523ZM39.93,5.86L41.47,5.86L41.47,13L39.93,13L39.93,5.86ZM45.498,12.958C45.218,12.855 44.989,12.72 44.812,12.552C44.411,12.151 44.21,11.605 44.21,10.914L44.21,7.218L42.964,7.218L42.964,5.86L44.21,5.86L44.21,3.844L45.75,3.844L45.75,5.86L47.486,5.86L47.486,7.218L45.75,7.218L45.75,10.578C45.75,10.961 45.825,11.231 45.974,11.39C46.114,11.577 46.357,11.67 46.702,11.67C46.861,11.67 47.001,11.649 47.122,11.607C47.243,11.565 47.374,11.497 47.514,11.404L47.514,12.902C47.206,13.042 46.833,13.112 46.394,13.112C46.077,13.112 45.778,13.061 45.498,12.958ZM49.176,2.976L50.716,2.976L50.716,5.706L50.646,6.798L50.716,6.798C50.921,6.462 51.227,6.184 51.633,5.965C52.039,5.746 52.475,5.636 52.942,5.636C53.81,5.636 54.473,5.89 54.93,6.399C55.387,6.908 55.616,7.601 55.616,8.478L55.616,13L54.076,13L54.076,8.688C54.076,8.147 53.934,7.741 53.649,7.47C53.364,7.199 52.993,7.064 52.536,7.064C52.191,7.064 51.88,7.162 51.605,7.358C51.33,7.554 51.113,7.813 50.954,8.135C50.795,8.457 50.716,8.8 50.716,9.164L50.716,13L49.176,13L49.176,2.976Z"
|
||||
android:strokeColor="#00000000"
|
||||
android:fillType="evenOdd"
|
||||
android:fillColor="#FFFFFF"
|
||||
android:strokeWidth="1"/>
|
||||
<path
|
||||
android:pathData="M81.526,2.635L81.526,6.718L84.044,6.718C84.644,6.718 85.14,6.516 85.532,6.113C85.935,5.711 86.137,5.231 86.137,4.676C86.137,4.132 85.935,3.658 85.532,3.254C85.14,2.841 84.644,2.634 84.044,2.634L81.526,2.634L81.526,2.635ZM81.526,8.155L81.526,12.891L80.022,12.891L80.022,1.198L84.011,1.198C85.025,1.198 85.885,1.535 86.594,2.21C87.314,2.885 87.674,3.707 87.674,4.676C87.674,5.667 87.314,6.495 86.594,7.158C85.897,7.823 85.035,8.154 84.011,8.154L81.526,8.154L81.526,8.155Z"
|
||||
android:strokeColor="#00000000"
|
||||
android:fillType="evenOdd"
|
||||
android:fillColor="#FFFFFF"
|
||||
android:strokeWidth="1"/>
|
||||
<path
|
||||
android:pathData="M89.194,10.442C89.194,10.834 89.36,11.16 89.693,11.422C90.025,11.683 90.415,11.813 90.861,11.813C91.494,11.813 92.057,11.579 92.553,11.112C93.05,10.643 93.297,10.093 93.297,9.463C92.828,9.092 92.174,8.907 91.335,8.907C90.724,8.907 90.215,9.055 89.807,9.349C89.398,9.643 89.194,10.006 89.194,10.442M91.14,4.627C92.252,4.627 93.129,4.924 93.773,5.518C94.415,6.111 94.737,6.925 94.737,7.959L94.737,12.891L93.298,12.891L93.298,11.781L93.233,11.781C92.611,12.695 91.783,13.153 90.747,13.153C89.865,13.153 89.126,12.891 88.532,12.369C87.938,11.846 87.641,11.193 87.641,10.409C87.641,9.581 87.954,8.923 88.581,8.433C89.208,7.943 90.044,7.698 91.09,7.698C91.983,7.698 92.72,7.861 93.297,8.188L93.297,7.844C93.297,7.322 93.09,6.878 92.676,6.513C92.261,6.149 91.777,5.967 91.221,5.967C90.381,5.967 89.717,6.32 89.226,7.029L87.902,6.195C88.632,5.15 89.711,4.627 91.14,4.627"
|
||||
android:strokeColor="#00000000"
|
||||
android:fillType="evenOdd"
|
||||
android:fillColor="#FFFFFF"
|
||||
android:strokeWidth="1"/>
|
||||
<path
|
||||
android:pathData="M102.993,4.889l-5.02,11.531l-1.553,0l1.864,-4.035l-3.303,-7.496l1.635,0l2.387,5.749l0.033,0l2.322,-5.749z"
|
||||
android:strokeColor="#00000000"
|
||||
android:fillType="evenOdd"
|
||||
android:fillColor="#FFFFFF"
|
||||
android:strokeWidth="1"/>
|
||||
<path
|
||||
android:pathData="M75.448,7.134C75.448,6.661 75.408,6.205 75.332,5.768L68.988,5.768L68.988,8.356L72.622,8.356C72.466,9.199 71.994,9.917 71.278,10.398L71.278,12.079L73.447,12.079C74.716,10.908 75.448,9.179 75.448,7.134"
|
||||
android:strokeColor="#00000000"
|
||||
android:fillType="evenOdd"
|
||||
android:fillColor="#4285F4"
|
||||
android:strokeWidth="1"/>
|
||||
<path
|
||||
android:pathData="M68.988,13.701C70.804,13.701 72.332,13.105 73.447,12.079L71.278,10.398C70.675,10.804 69.897,11.041 68.988,11.041C67.234,11.041 65.744,9.859 65.212,8.267L62.978,8.267L62.978,9.998C64.085,12.193 66.36,13.701 68.988,13.701"
|
||||
android:strokeColor="#00000000"
|
||||
android:fillType="evenOdd"
|
||||
android:fillColor="#34A853"
|
||||
android:strokeWidth="1"/>
|
||||
<path
|
||||
android:pathData="M65.212,8.267C65.076,7.861 65.001,7.428 65.001,6.981C65.001,6.534 65.076,6.101 65.212,5.695L65.212,3.964L62.978,3.964C62.52,4.871 62.261,5.896 62.261,6.981C62.261,8.066 62.52,9.091 62.978,9.998L65.212,8.267Z"
|
||||
android:strokeColor="#00000000"
|
||||
android:fillType="evenOdd"
|
||||
android:fillColor="#FABB05"
|
||||
android:strokeWidth="1"/>
|
||||
<path
|
||||
android:pathData="M68.988,2.921C69.98,2.921 70.868,3.262 71.569,3.929L71.569,3.93L73.489,2.012C72.323,0.928 70.803,0.261 68.988,0.261C66.36,0.261 64.085,1.769 62.978,3.964L65.212,5.695C65.744,4.103 67.234,2.921 68.988,2.921"
|
||||
android:strokeColor="#00000000"
|
||||
android:fillType="evenOdd"
|
||||
android:fillColor="#E94235"
|
||||
android:strokeWidth="1"/>
|
||||
</vector>
|
After Width: | Height: | Size: 979 B |
|
@ -0,0 +1,5 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<selector xmlns:android="http://schemas.android.com/apk/res/android" >
|
||||
<item
|
||||
android:drawable="@drawable/googlepay_button_no_shadow_background_image" />
|
||||
</selector>
|
After Width: | Height: | Size: 282 B |
|
@ -0,0 +1,5 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<selector xmlns:android="http://schemas.android.com/apk/res/android" >
|
||||
<item
|
||||
android:drawable="@drawable/googlepay_button_background_image" />
|
||||
</selector>
|
|
@ -0,0 +1,170 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<path
|
||||
android:fillColor="#3DDC84"
|
||||
android:pathData="M0,0h108v108h-108z" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M9,0L9,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,0L19,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M29,0L29,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M39,0L39,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M49,0L49,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M59,0L59,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M69,0L69,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M79,0L79,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M89,0L89,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M99,0L99,108"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,9L108,9"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,19L108,19"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,29L108,29"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,39L108,39"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,49L108,49"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,59L108,59"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,69L108,69"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,79L108,79"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,89L108,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M0,99L108,99"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,29L89,29"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,39L89,39"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,49L89,49"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,59L89,59"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,69L89,69"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M19,79L89,79"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M29,19L29,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M39,19L39,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M49,19L49,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M59,19L59,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M69,19L69,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
<path
|
||||
android:fillColor="#00000000"
|
||||
android:pathData="M79,19L79,89"
|
||||
android:strokeWidth="0.8"
|
||||
android:strokeColor="#33FFFFFF" />
|
||||
</vector>
|
|
@ -0,0 +1,30 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:aapt="http://schemas.android.com/aapt"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="108"
|
||||
android:viewportHeight="108">
|
||||
<path android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z">
|
||||
<aapt:attr name="android:fillColor">
|
||||
<gradient
|
||||
android:endX="85.84757"
|
||||
android:endY="92.4963"
|
||||
android:startX="42.9492"
|
||||
android:startY="49.59793"
|
||||
android:type="linear">
|
||||
<item
|
||||
android:color="#44000000"
|
||||
android:offset="0.0" />
|
||||
<item
|
||||
android:color="#00000000"
|
||||
android:offset="1.0" />
|
||||
</gradient>
|
||||
</aapt:attr>
|
||||
</path>
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:fillType="nonZero"
|
||||
android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z"
|
||||
android:strokeWidth="1"
|
||||
android:strokeColor="#00000000" />
|
||||
</vector>
|
|
@ -0,0 +1,78 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
Copyright (C) Google
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
-->
|
||||
|
||||
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="#fff"
|
||||
android:fillViewport="true"
|
||||
tools:context=".MainActivity">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:layout_marginStart="20dp"
|
||||
android:layout_marginEnd="20dp">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/detailTitle"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="15dp"
|
||||
android:text="Lettuce"
|
||||
android:textColor="#333333"
|
||||
android:textSize="20sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/detailPrice"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="5dp"
|
||||
android:textColor="#777777"
|
||||
android:text="250.73 RUB"/>
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="15dp"
|
||||
android:text="Description"
|
||||
android:textColor="#333333"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/detailDescription"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="5dp"
|
||||
android:layout_marginBottom="15dp"
|
||||
android:textColor="#777777"
|
||||
android:text="A super green super-food"/>
|
||||
|
||||
<include
|
||||
android:id="@+id/googlePayButton"
|
||||
layout="@layout/buy_with_googlepay_button"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="@dimen/buy_button_height"
|
||||
android:layout_marginBottom="20dp"
|
||||
android:visibility="gone"/>
|
||||
|
||||
</LinearLayout>
|
||||
|
||||
</ScrollView>
|
|
@ -0,0 +1,31 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:clickable="true"
|
||||
android:focusable="true"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="48sp"
|
||||
android:background="@drawable/googlepay_button_no_shadow_background"
|
||||
android:padding="2sp"
|
||||
android:contentDescription="@string/buy_with_googlepay_button_content_description">
|
||||
<LinearLayout
|
||||
android:duplicateParentState="true"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:weightSum="2"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="vertical">
|
||||
<ImageView
|
||||
android:layout_weight="1"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
android:scaleType="fitCenter"
|
||||
android:duplicateParentState="true"
|
||||
android:src="@drawable/buy_with_googlepay_button_content"/>
|
||||
</LinearLayout>
|
||||
<ImageView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:scaleType="fitXY"
|
||||
android:duplicateParentState="true"
|
||||
android:src="@drawable/googlepay_button_overlay"/>
|
||||
</RelativeLayout>
|
|
@ -0,0 +1,5 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@drawable/ic_launcher_background" />
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||
</adaptive-icon>
|
|
@ -0,0 +1,5 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@drawable/ic_launcher_background" />
|
||||
<foreground android:drawable="@drawable/ic_launcher_foreground" />
|
||||
</adaptive-icon>
|
After Width: | Height: | Size: 3.5 KiB |
After Width: | Height: | Size: 5.2 KiB |
After Width: | Height: | Size: 2.6 KiB |
After Width: | Height: | Size: 3.3 KiB |
After Width: | Height: | Size: 4.8 KiB |
After Width: | Height: | Size: 7.3 KiB |
After Width: | Height: | Size: 7.7 KiB |
After Width: | Height: | Size: 12 KiB |
After Width: | Height: | Size: 10 KiB |
After Width: | Height: | Size: 16 KiB |
|
@ -0,0 +1,16 @@
|
|||
<resources xmlns:tools="http://schemas.android.com/tools">
|
||||
<!-- Base application theme. -->
|
||||
<style name="Theme.AcquiringLib" parent="Theme.MaterialComponents.DayNight.DarkActionBar">
|
||||
<!-- Primary brand color. -->
|
||||
<item name="colorPrimary">@color/purple_200</item>
|
||||
<item name="colorPrimaryVariant">@color/purple_700</item>
|
||||
<item name="colorOnPrimary">@color/black</item>
|
||||
<!-- Secondary brand color. -->
|
||||
<item name="colorSecondary">@color/teal_200</item>
|
||||
<item name="colorSecondaryVariant">@color/teal_200</item>
|
||||
<item name="colorOnSecondary">@color/black</item>
|
||||
<!-- Status bar color. -->
|
||||
<item name="android:statusBarColor" tools:targetApi="l">?attr/colorPrimaryVariant</item>
|
||||
<!-- Customize your theme here. -->
|
||||
</style>
|
||||
</resources>
|
|
@ -0,0 +1,10 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="purple_200">#FFBB86FC</color>
|
||||
<color name="purple_500">#FF6200EE</color>
|
||||
<color name="purple_700">#FF3700B3</color>
|
||||
<color name="teal_200">#FF03DAC5</color>
|
||||
<color name="teal_700">#FF018786</color>
|
||||
<color name="black">#FF000000</color>
|
||||
<color name="white">#FFFFFFFF</color>
|
||||
</resources>
|
|
@ -0,0 +1,28 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
Copyright (C) Google
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
-->
|
||||
|
||||
<resources>
|
||||
|
||||
<dimen name="padding_small">5dp</dimen>
|
||||
<dimen name="margin_large">10dp</dimen>
|
||||
<dimen name="margin_xlarge">30dp</dimen>
|
||||
|
||||
<dimen name="buy_button_width">300dp</dimen>
|
||||
<dimen name="buy_button_height">48sp</dimen>
|
||||
<dimen name="buy_button_min_width">200dp</dimen>
|
||||
|
||||
</resources>
|
|
@ -0,0 +1,4 @@
|
|||
<resources>
|
||||
<string name="app_name">Acquiring Sample</string>
|
||||
<string name="buy_with_googlepay_button_content_description">Buy with Google Pay</string>
|
||||
</resources>
|
|
@ -0,0 +1,16 @@
|
|||
<resources xmlns:tools="http://schemas.android.com/tools">
|
||||
<!-- Base application theme. -->
|
||||
<style name="Theme.AcquiringLib" parent="Theme.MaterialComponents.DayNight.DarkActionBar">
|
||||
<!-- Primary brand color. -->
|
||||
<item name="colorPrimary">@color/purple_500</item>
|
||||
<item name="colorPrimaryVariant">@color/purple_700</item>
|
||||
<item name="colorOnPrimary">@color/white</item>
|
||||
<!-- Secondary brand color. -->
|
||||
<item name="colorSecondary">@color/teal_200</item>
|
||||
<item name="colorSecondaryVariant">@color/teal_700</item>
|
||||
<item name="colorOnSecondary">@color/black</item>
|
||||
<!-- Status bar color. -->
|
||||
<item name="android:statusBarColor" tools:targetApi="l">?attr/colorPrimaryVariant</item>
|
||||
<!-- Customize your theme here. -->
|
||||
</style>
|
||||
</resources>
|
|
@ -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)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,3 @@
|
|||
include ':library'
|
||||
include ':sample'
|
||||
rootProject.name = "Acquiring Lib"
|