diff --git a/build.gradle b/build.gradle
index a0d2190..c665db9 100644
--- a/build.gradle
+++ b/build.gradle
@@ -1,6 +1,7 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules.
buildscript {
+ ext.kotlin_version = '1.3.72'
repositories {
jcenter()
google()
@@ -10,6 +11,7 @@ buildscript {
classpath 'org.robolectric:robolectric-gradle-plugin:1.1.0'
classpath 'com.jfrog.bintray.gradle:gradle-bintray-plugin:1.8.5'
classpath 'com.github.dcendents:android-maven-gradle-plugin:1.5'
+ 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
}
@@ -19,6 +21,7 @@ allprojects {
repositories {
jcenter()
google()
+ mavenCentral()
}
}
@@ -26,9 +29,9 @@ ext {
compileSdkVersion = 29
minSdkVersion = 16
targetSdkVersion = 29
- versionCode = 27
+ versionCode = 18
buildToolsVersion = "29.0.2"
supportLibraryVersion = "28.0.0"
- versionName = "3.0.19"
+ versionName = "3.1.1"
}
\ No newline at end of file
diff --git a/paystack/build.gradle b/paystack/build.gradle
index 52caded..39aff98 100644
--- a/paystack/build.gradle
+++ b/paystack/build.gradle
@@ -1,4 +1,5 @@
apply plugin: 'com.android.library'
+apply plugin: 'kotlin-android'
apply plugin: "com.jfrog.bintray"
apply plugin: 'com.github.dcendents.android-maven'
apply plugin: 'maven-publish'
@@ -30,6 +31,9 @@ dependencies {
implementation 'com.squareup.okhttp3:okhttp:3.14.9'
implementation "com.android.support:appcompat-v7:$rootProject.ext.supportLibraryVersion"
implementation 'co.paystack.android.design.widget:pinpad:1.0.4'
+ implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlin_version"
+ implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:1.3.7"
+ implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:1.3.7"
}
project.afterEvaluate {
diff --git a/paystack/src/main/AndroidManifest.xml b/paystack/src/main/AndroidManifest.xml
index a98426b..fee01b5 100644
--- a/paystack/src/main/AndroidManifest.xml
+++ b/paystack/src/main/AndroidManifest.xml
@@ -1,20 +1,23 @@
+ package="co.paystack.android">
+ android:theme="@style/Paystack.Dialog.PinEntry" />
+ android:theme="@style/Paystack.Dialog.OtpEntry" />
+ android:theme="@style/Paystack.Dialog.CardEntry" />
+
\ No newline at end of file
diff --git a/paystack/src/main/java/co/paystack/android/TransactionManager.java b/paystack/src/main/java/co/paystack/android/TransactionManager.java
index 64019c3..c978a43 100644
--- a/paystack/src/main/java/co/paystack/android/TransactionManager.java
+++ b/paystack/src/main/java/co/paystack/android/TransactionManager.java
@@ -22,6 +22,9 @@
import co.paystack.android.exceptions.ProcessingException;
import co.paystack.android.model.Card;
import co.paystack.android.model.Charge;
+import co.paystack.android.ui.AddressHolder;
+import co.paystack.android.ui.AddressHolder.Address;
+import co.paystack.android.ui.AddressVerificationActivity;
import co.paystack.android.ui.AuthActivity;
import co.paystack.android.ui.AuthSingleton;
import co.paystack.android.ui.CardActivity;
@@ -46,6 +49,7 @@ class TransactionManager {
private final PinSingleton psi = PinSingleton.getInstance();
private final OtpSingleton osi = OtpSingleton.getInstance();
private final AuthSingleton asi = AuthSingleton.getInstance();
+ private final AddressHolder addressHolder = AddressHolder.getInstance();
private ChargeRequestBody chargeRequestBody;
private ValidateRequestBody validateRequestBody;
private ApiService apiService;
@@ -97,7 +101,7 @@ void chargeCard() {
try {
if (charge.getCard() == null || !charge.getCard().isValid()) {
final CardSingleton si = CardSingleton.getInstance();
- synchronized (si){
+ synchronized (si) {
si.setCard(charge.getCard());
}
new CardAsyncTask().execute();
@@ -121,7 +125,6 @@ private void sendChargeToServer() {
Log.e(LOG_TAG, ce.getMessage(), ce);
notifyProcessingError(ce);
}
-
}
private void validate() {
@@ -144,6 +147,19 @@ private void reQuery() {
}
+
+ private void chargeWithAvs(Address address) {
+ HashMap fields = address.toHashMap();
+ fields.put("trans", transaction.getId());
+ try {
+ Call call = apiService.submitCardAddress(fields);
+ call.enqueue(serverCallback);
+ } catch (Exception e) {
+ Log.e(LOG_TAG, e.getMessage(), e);
+ notifyProcessingError(e);
+ }
+ }
+
private void validateChargeOnServer() throws KeyManagementException, NoSuchAlgorithmException, KeyStoreException {
HashMap params = validateRequestBody.getParamsHashMap();
Call call = apiService.validateCharge(params);
@@ -165,6 +181,12 @@ private void handleApiResponse(TransactionApiResponse transactionApiResponse) {
if (transactionApiResponse == null) {
transactionApiResponse = TransactionApiResponse.unknownServerResponse();
}
+
+ // The AVS charge endpoint sends an "errors" object when address verification fails
+ if (transactionApiResponse.hasErrors) {
+ notifyProcessingError(new ChargeException(transactionApiResponse.message));
+ return;
+ }
transaction.loadFromResponse(transactionApiResponse);
if (transactionApiResponse.status.equalsIgnoreCase("1") || transactionApiResponse.status.equalsIgnoreCase("success")) {
@@ -173,6 +195,11 @@ private void handleApiResponse(TransactionApiResponse transactionApiResponse) {
return;
}
+ if (transactionApiResponse.status.equalsIgnoreCase("2") && transactionApiResponse.auth.equalsIgnoreCase("avs")) {
+ new AddressVerificationAsyncTask().execute(transactionApiResponse.avsCountryCode);
+ return;
+ }
+
if (transactionApiResponse.status.equalsIgnoreCase("2") || (transactionApiResponse.hasValidAuth() && (transactionApiResponse.auth.equalsIgnoreCase("pin")))) {
new PinAsyncTask().execute();
return;
@@ -362,4 +389,36 @@ protected void onPostExecute(String responseJson) {
}
}
+ private class AddressVerificationAsyncTask extends AsyncTask {
+
+
+ @Override
+ protected Address doInBackground(String... params) {
+ Intent i = new Intent(activity, AddressVerificationActivity.class);
+ i.putExtra(AddressVerificationActivity.EXTRA_COUNTRY_CODE, params[0]);
+ activity.startActivity(i);
+ synchronized (AddressHolder.getLock()) {
+ try {
+ AddressHolder.getLock().wait();
+ } catch (InterruptedException e) {
+ notifyProcessingError(new Exception("Address entry Interrupted"));
+ }
+ }
+
+ return addressHolder.getAddress();
+ }
+
+ @Override
+ protected void onPostExecute(Address address) {
+ super.onPostExecute(address);
+
+ if (address != null) {
+ Log.e("AVS_ADDRESS", address.toString());
+ chargeWithAvs(address);
+
+ } else {
+ notifyProcessingError(new Exception("No address provided"));
+ }
+ }
+ }
}
\ No newline at end of file
diff --git a/paystack/src/main/java/co/paystack/android/api/model/ApiResponse.java b/paystack/src/main/java/co/paystack/android/api/model/ApiResponse.java
index 3b6f07f..11ea72a 100644
--- a/paystack/src/main/java/co/paystack/android/api/model/ApiResponse.java
+++ b/paystack/src/main/java/co/paystack/android/api/model/ApiResponse.java
@@ -13,4 +13,6 @@ public class ApiResponse extends BaseApiModel {
@SerializedName("message")
public String message;
+ @SerializedName("errors")
+ public boolean hasErrors = false;
}
diff --git a/paystack/src/main/java/co/paystack/android/api/model/TransactionApiResponse.java b/paystack/src/main/java/co/paystack/android/api/model/TransactionApiResponse.java
index 3ee37ba..dc6e1ae 100644
--- a/paystack/src/main/java/co/paystack/android/api/model/TransactionApiResponse.java
+++ b/paystack/src/main/java/co/paystack/android/api/model/TransactionApiResponse.java
@@ -24,6 +24,9 @@ public class TransactionApiResponse extends ApiResponse implements Serializable
@SerializedName("otpmessage")
public String otpmessage;
+ @SerializedName("countryCode")
+ public String avsCountryCode; // Country code for Address Verification on supported international cards
+
public static TransactionApiResponse unknownServerResponse() {
TransactionApiResponse t = new TransactionApiResponse();
t.status = "0";
diff --git a/paystack/src/main/java/co/paystack/android/api/service/ApiService.java b/paystack/src/main/java/co/paystack/android/api/service/ApiService.java
index c3bc367..fe3e7c3 100644
--- a/paystack/src/main/java/co/paystack/android/api/service/ApiService.java
+++ b/paystack/src/main/java/co/paystack/android/api/service/ApiService.java
@@ -26,5 +26,8 @@ public interface ApiService {
@GET("/requery/{trans}")
Call requeryTransaction(@Path("trans") String trans);
+ @FormUrlEncoded
+ @POST("/charge/avs")
+ Call submitCardAddress(@FieldMap HashMap fields);
}
diff --git a/paystack/src/main/java/co/paystack/android/api/service/PaystackApiFactory.kt b/paystack/src/main/java/co/paystack/android/api/service/PaystackApiFactory.kt
new file mode 100644
index 0000000..2973d6f
--- /dev/null
+++ b/paystack/src/main/java/co/paystack/android/api/service/PaystackApiFactory.kt
@@ -0,0 +1,58 @@
+package co.paystack.android.mobilemoney.data.api
+
+import android.os.Build
+import co.paystack.android.BuildConfig
+import co.paystack.android.api.service.PaystackApiService
+import co.paystack.android.api.service.converter.WrappedResponseConverter
+import co.paystack.android.api.utils.TLSSocketFactory
+import com.google.gson.GsonBuilder
+import okhttp3.OkHttpClient
+import retrofit2.Retrofit
+import retrofit2.converter.gson.GsonConverterFactory
+import java.security.KeyManagementException
+import java.security.KeyStoreException
+import java.security.NoSuchAlgorithmException
+import java.util.concurrent.TimeUnit
+
+/*
+* Generates an API client for new paystack API (https://api.paystack.co)
+* */
+internal object PaystackApiFactory {
+ private const val BASE_URL = "https://api.paystack.co/"
+
+ @Throws(NoSuchAlgorithmException::class, KeyManagementException::class, KeyStoreException::class)
+ fun createRetrofitService(): PaystackApiService {
+ val gson = GsonBuilder()
+ .setDateFormat("yyyy'-'MM'-'dd'T'HH':'mm':'ss'.'SSS'Z'")
+ .create()
+
+ val tlsV1point2factory = TLSSocketFactory()
+ val okHttpClient = OkHttpClient.Builder()
+ .addInterceptor { chain ->
+ val original = chain.request()
+ // Add headers so we get Android version and Paystack Library version
+ val builder = original.newBuilder()
+ .header("User-Agent", "Android_" + Build.VERSION.SDK_INT + "_Paystack_" + BuildConfig.VERSION_NAME)
+ .header("X-Paystack-Build", BuildConfig.VERSION_CODE.toString())
+ .header("Accept", "application/json")
+ .method(original.method(), original.body())
+ val request = builder.build()
+ chain.proceed(request)
+ }
+ .sslSocketFactory(tlsV1point2factory, tlsV1point2factory.x509TrustManager)
+ .connectTimeout(1, TimeUnit.MINUTES)
+ .readTimeout(1, TimeUnit.MINUTES)
+ .writeTimeout(1, TimeUnit.MINUTES)
+ .build()
+
+ val retrofit = Retrofit.Builder()
+ .baseUrl(BASE_URL)
+ .client(okHttpClient)
+ .addConverterFactory(WrappedResponseConverter.Factory())
+ .addConverterFactory(GsonConverterFactory.create(gson))
+ .build()
+
+ return retrofit.create(PaystackApiService::class.java)
+ }
+
+}
\ No newline at end of file
diff --git a/paystack/src/main/java/co/paystack/android/api/service/PaystackApiService.kt b/paystack/src/main/java/co/paystack/android/api/service/PaystackApiService.kt
new file mode 100644
index 0000000..13d3a8e
--- /dev/null
+++ b/paystack/src/main/java/co/paystack/android/api/service/PaystackApiService.kt
@@ -0,0 +1,10 @@
+package co.paystack.android.api.service
+
+import co.paystack.android.model.AvsState
+import retrofit2.http.GET
+import retrofit2.http.Query
+
+internal interface PaystackApiService {
+ @GET("/address_verification/states")
+ suspend fun getAddressVerificationStates(@Query("country") countryCode: String): List
+}
\ No newline at end of file
diff --git a/paystack/src/main/java/co/paystack/android/api/service/converter/WrappedResponseConverter.kt b/paystack/src/main/java/co/paystack/android/api/service/converter/WrappedResponseConverter.kt
new file mode 100644
index 0000000..0a73699
--- /dev/null
+++ b/paystack/src/main/java/co/paystack/android/api/service/converter/WrappedResponseConverter.kt
@@ -0,0 +1,49 @@
+package co.paystack.android.api.service.converter
+
+import okhttp3.ResponseBody
+import retrofit2.Converter
+import retrofit2.Retrofit
+import java.lang.reflect.ParameterizedType
+import java.lang.reflect.Type
+
+class WrappedResponseConverter(
+ private val delegate: Converter>
+) : Converter {
+ override fun convert(value: ResponseBody): T? {
+ val response = delegate.convert(value)
+ return response?.data
+ }
+
+
+ class Factory : Converter.Factory() {
+ override fun responseBodyConverter(
+ type: Type,
+ annotations: Array,
+ retrofit: Retrofit
+ ): Converter? {
+ val wrappedType: Type = object : ParameterizedType {
+ override fun getRawType(): Type {
+ return WrappedResponse::class.java
+ }
+
+ override fun getOwnerType(): Type? {
+ return null
+ }
+
+ override fun getActualTypeArguments(): Array {
+ return arrayOf(type)
+ }
+ }
+
+ val delegate = retrofit.nextResponseBodyConverter>(this, wrappedType, annotations)
+ return WrappedResponseConverter(delegate)
+
+ }
+ }
+
+ open class WrappedResponse(
+ val `data`: T,
+ val message: String,
+ val status: Boolean
+ )
+}
\ No newline at end of file
diff --git a/paystack/src/main/java/co/paystack/android/model/AvsState.kt b/paystack/src/main/java/co/paystack/android/model/AvsState.kt
new file mode 100644
index 0000000..06e6813
--- /dev/null
+++ b/paystack/src/main/java/co/paystack/android/model/AvsState.kt
@@ -0,0 +1,7 @@
+package co.paystack.android.model
+
+data class AvsState(
+ val name: String,
+ val slug: String,
+ val abbreviation: String
+)
\ No newline at end of file
diff --git a/paystack/src/main/java/co/paystack/android/ui/AddressHolder.java b/paystack/src/main/java/co/paystack/android/ui/AddressHolder.java
new file mode 100644
index 0000000..e40eaed
--- /dev/null
+++ b/paystack/src/main/java/co/paystack/android/ui/AddressHolder.java
@@ -0,0 +1,92 @@
+package co.paystack.android.ui;
+
+import java.util.HashMap;
+
+public class AddressHolder {
+ private static AddressHolder instance = new AddressHolder();
+ private static Object lock = new Object();
+ private Address address = null;
+
+ private AddressHolder() {
+ }
+
+ public static AddressHolder getInstance() {
+ return instance;
+ }
+
+ public static Object getLock() {
+ return lock;
+ }
+
+ public Address getAddress() {
+ return address;
+ }
+
+ public void setAddress(Address address) {
+ this.address = address;
+ }
+
+ public static class Address {
+ private String state = "";
+ private String zipCode = "";
+ private String city = "";
+ private String street = "";
+
+ public static String FIELD_ADDRESS = "address";
+ public static String FIELD_CITY = "city";
+ public static String FIELD_ZIP_CODE = "zip_code";
+ public static String FIELD_STATE = "state";
+
+ public String getState() {
+ return state;
+ }
+
+ public void setState(String state) {
+ this.state = state;
+ }
+
+ public String getZipCode() {
+ return zipCode;
+ }
+
+ public void setZipCode(String zipCode) {
+ this.zipCode = zipCode;
+ }
+
+ public String getCity() {
+ return city;
+ }
+
+ public void setCity(String city) {
+ this.city = city;
+ }
+
+ public String getStreet() {
+ return street;
+ }
+
+ public void setStreet(String street) {
+ this.street = street;
+ }
+
+ @Override
+ public String toString() {
+ return "Address{" +
+ "state='" + state + '\'' +
+ ", zipCode='" + zipCode + '\'' +
+ ", city='" + city + '\'' +
+ ", street='" + street + '\'' +
+ '}';
+ }
+
+ public HashMap toHashMap() {
+ HashMap params = new HashMap<>();
+ params.put(FIELD_STATE, this.state);
+ params.put(FIELD_ZIP_CODE, this.zipCode);
+ params.put(FIELD_CITY, this.city);
+ params.put(FIELD_ADDRESS, this.street);
+ return params;
+ }
+ }
+
+}
\ No newline at end of file
diff --git a/paystack/src/main/java/co/paystack/android/ui/AddressVerificationActivity.kt b/paystack/src/main/java/co/paystack/android/ui/AddressVerificationActivity.kt
new file mode 100644
index 0000000..9c94144
--- /dev/null
+++ b/paystack/src/main/java/co/paystack/android/ui/AddressVerificationActivity.kt
@@ -0,0 +1,159 @@
+package co.paystack.android.ui
+
+import android.os.Bundle
+import android.support.v7.app.AppCompatActivity
+import android.support.v7.widget.ListPopupWindow
+import android.text.Editable
+import android.text.TextWatcher
+import android.util.Log
+import android.view.View
+import android.view.WindowManager
+import android.widget.*
+import co.paystack.android.R
+import co.paystack.android.mobilemoney.data.api.PaystackApiFactory
+import co.paystack.android.model.AvsState
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.launch
+import kotlin.coroutines.CoroutineContext
+import kotlin.properties.Delegates
+
+class AddressVerificationActivity : AppCompatActivity(), CoroutineScope {
+ private lateinit var job: Job
+ override val coroutineContext: CoroutineContext
+ get() = job + Dispatchers.Main
+
+ private val addressHolder = AddressHolder.getInstance()
+ private val lock = AddressHolder.getLock()
+
+
+ private val paystackApiService by lazy {
+ PaystackApiFactory.createRetrofitService()
+ }
+
+ private val etState by lazy { findViewById(R.id.etState) }
+ private val etStreet by lazy { findViewById(R.id.etStreet) }
+ private val etCity by lazy { findViewById(R.id.etCity) }
+ private val etZipCode by lazy { findViewById(R.id.etZipCode) }
+ private val tvError by lazy { findViewById(R.id.tvError) }
+ private val btnRetry by lazy { findViewById