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"
|