diff --git a/.gitignore b/.gitignore index d127418..1e78739 100644 --- a/.gitignore +++ b/.gitignore @@ -80,4 +80,5 @@ lint/intermediates/ lint/generated/ lint/outputs/ lint/tmp/ -# lint/reports/ \ No newline at end of file +# lint/reports/ +.idea \ No newline at end of file diff --git a/.idea/codeStyles/Project.xml b/.idea/codeStyles/Project.xml deleted file mode 100644 index 681f41a..0000000 --- a/.idea/codeStyles/Project.xml +++ /dev/null @@ -1,116 +0,0 @@ - - - - - - - -
- - - - xmlns:android - - ^$ - - - -
-
- - - - xmlns:.* - - ^$ - - - BY_NAME - -
-
- - - - .*:id - - http://schemas.android.com/apk/res/android - - - -
-
- - - - .*:name - - http://schemas.android.com/apk/res/android - - - -
-
- - - - name - - ^$ - - - -
-
- - - - style - - ^$ - - - -
-
- - - - .* - - ^$ - - - BY_NAME - -
-
- - - - .* - - http://schemas.android.com/apk/res/android - - - ANDROID_ATTRIBUTE_ORDER - -
-
- - - - .* - - .* - - - BY_NAME - -
-
-
-
-
-
\ No newline at end of file diff --git a/.idea/compiler.xml b/.idea/compiler.xml deleted file mode 100644 index a21a80b..0000000 --- a/.idea/compiler.xml +++ /dev/null @@ -1,11 +0,0 @@ - - - - - \ No newline at end of file diff --git a/.idea/encodings.xml b/.idea/encodings.xml deleted file mode 100644 index 97626ba..0000000 --- a/.idea/encodings.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/.idea/gradle.xml b/.idea/gradle.xml deleted file mode 100644 index d291b3d..0000000 --- a/.idea/gradle.xml +++ /dev/null @@ -1,16 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml deleted file mode 100644 index 2761f20..0000000 --- a/.idea/misc.xml +++ /dev/null @@ -1,17 +0,0 @@ - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/.idea/runConfigurations.xml b/.idea/runConfigurations.xml deleted file mode 100644 index 7f68460..0000000 --- a/.idea/runConfigurations.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml deleted file mode 100644 index 35eb1dd..0000000 --- a/.idea/vcs.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/app/src/main/java/com/monri/android/example/PaymentPickerActivity.java b/app/src/main/java/com/monri/android/example/PaymentPickerActivity.java index 0c566cb..c309923 100644 --- a/app/src/main/java/com/monri/android/example/PaymentPickerActivity.java +++ b/app/src/main/java/com/monri/android/example/PaymentPickerActivity.java @@ -129,11 +129,11 @@ Consumer handlePaymentSessionResponse(SupplierTesting documentation - */ -@RunWith(AndroidJUnit4.class) -public class ExampleInstrumentedTest { - @Test - public void useAppContext() { - // Context of the app under test. - Context appContext = InstrumentationRegistry.getInstrumentation().getTargetContext(); - - assertEquals("com.monri.android.test", appContext.getPackageName()); - } -} diff --git a/monri/src/main/java/com/monri/android/MonriTextUtils.java b/monri/src/main/java/com/monri/android/MonriTextUtils.java index 08aa1ce..2b68f47 100644 --- a/monri/src/main/java/com/monri/android/MonriTextUtils.java +++ b/monri/src/main/java/com/monri/android/MonriTextUtils.java @@ -161,4 +161,18 @@ private static String bytesToHex(byte[] bytes) { } return new String(hexChars); } + + /** + * Returns whether the given CharSequence contains only digits. + */ + public static boolean isDigitsOnly(CharSequence str) { + final int len = str.length(); + for (int cp, i = 0; i < len; i += Character.charCount(cp)) { + cp = Character.codePointAt(str, i); + if (!Character.isDigit(cp)) { + return false; + } + } + return true; + } } diff --git a/monri/src/main/java/com/monri/android/model/AdditionalData.java b/monri/src/main/java/com/monri/android/model/AdditionalData.java new file mode 100644 index 0000000..48b57e9 --- /dev/null +++ b/monri/src/main/java/com/monri/android/model/AdditionalData.java @@ -0,0 +1,95 @@ +package com.monri.android.model; + +import java.util.Map; + +enum AdditionalData { + //todo consider min and max length that can be included in regex.. + //todo meta data is map no validation? + FULL_NAME(ValidationType.REGEX, Constants.FULL_NAME, Constants.ALPHA_NUMERIC_REGEX, 3, 30), + ADDRESS(ValidationType.REGEX, Constants.ADDRESS, Constants.ALPHA_NUMERIC_REGEX, 3, 100), + CITY(ValidationType.REGEX, Constants.CITY, Constants.ALPHA_NUMERIC_REGEX, 3, 30), + ZIP(ValidationType.REGEX, Constants.ZIP, Constants.ALPHA_NUMERIC_REGEX, 3, 9), + COUNTRY(ValidationType.REGEX, Constants.COUNTRY, Constants.ALPHA_NUMERIC_REGEX, 3, 30), + PHONE(ValidationType.REGEX, Constants.PHONE, Constants.PHONE_NUMBER_REGEX, 3, 30), + EMAIL(ValidationType.REGEX, Constants.EMAIL, Constants.EMAIL_REGEX, 3, 100), + META_DATA(ValidationType.MAP_SIZE_VALIDATION, Constants.META_DATA, "", 0, 255); + + private final ValidationType validationType; + private final String fieldName; + private final String validationData; + private final Integer minLength; + private final Integer maxLength; + + AdditionalData(final ValidationType validationType, final String fieldName, + final String validationData, + final Integer minLength, + final Integer maxLength) { + this.validationType = validationType; + this.fieldName = fieldName; + this.validationData = validationData; + this.minLength = minLength; + this.maxLength = maxLength; + } + + public static AdditionalData fromValue(final String additionalDataKey) { + switch (additionalDataKey) { + case "ch_full_name": + return FULL_NAME; + case "ch_address": + return ADDRESS; + case "ch_city": + return CITY; + case "ch_zip": + return ZIP; + case "ch_country": + return COUNTRY; + case "ch_phone": + return PHONE; + case "ch_email": + return EMAIL; + case "meta_data": + return META_DATA; + default: + throw new IllegalArgumentException(additionalDataKey + " is not supported "); + } + } + + public String getFieldName() { + return fieldName; + } + + public boolean isValid(final Object fieldValue) { + switch (validationType) { + case REGEX: + if (!(fieldValue instanceof String)) { + return false; + } + final String value = (String) fieldValue; + return value.matches(validationData) && value.length() >= minLength && value.length() <= maxLength; + case MAP_SIZE_VALIDATION: + if (!(fieldValue instanceof Map)) { + return false; + } + final Map stringObjectMap = (Map) fieldValue; + return stringObjectMap.size() >= minLength && stringObjectMap.size() <= maxLength; + default: + throw new IllegalArgumentException("Unknown validation type"); + } + + } + + static class Constants { + static final String FULL_NAME = "ch_full_name"; + static final String ADDRESS = "ch_address"; + static final String CITY = "ch_city"; + static final String ZIP = "ch_zip"; + static final String COUNTRY = "ch_country"; + static final String PHONE = "ch_phone"; + static final String EMAIL = "ch_email"; + static final String META_DATA = "meta_data"; + + private static final String ALPHA_NUMERIC_REGEX = "^[\\p{L}\\p{Z}\\p{N}\\.]+$"; + private static final String EMAIL_REGEX = "^\\w+@[a-zA-Z_]+?\\.[a-zA-Z]{2,3}$"; + private static final String PHONE_NUMBER_REGEX = "^[+]*[(]{0,1}[0-9]{1,4}[)]{0,1}[-\\s\\./0-9]*$"; + } +} diff --git a/monri/src/main/java/com/monri/android/model/Card.java b/monri/src/main/java/com/monri/android/model/Card.java index c194adb..9e22fae 100644 --- a/monri/src/main/java/com/monri/android/model/Card.java +++ b/monri/src/main/java/com/monri/android/model/Card.java @@ -15,6 +15,7 @@ import java.lang.annotation.Retention; import java.lang.annotation.RetentionPolicy; import java.util.Calendar; +import java.util.Collections; import java.util.HashMap; import java.util.Map; @@ -46,15 +47,15 @@ public Map data() { @Retention(RetentionPolicy.SOURCE) @StringDef({ - AMERICAN_EXPRESS, - DISCOVER, - JCB, - DINERS_CLUB, - VISA, - MASTERCARD, - UNIONPAY, - UNKNOWN - }) + AMERICAN_EXPRESS, + DISCOVER, + JCB, + DINERS_CLUB, + VISA, + MASTERCARD, + UNIONPAY, + UNKNOWN + }) public @interface CardBrand { } @@ -112,9 +113,12 @@ public Map data() { private Integer expMonth; private Integer expYear; private boolean tokenizePan; + private Map data; - @Size(4) private String last4; - @CardBrand private String brand; + @Size(4) + private String last4; + @CardBrand + private String brand; /** * Converts an unchecked String value to a {@link CardBrand} or {@code null}. @@ -149,24 +153,213 @@ public static String asCardBrand(@Nullable String possibleCardType) { } /** - * Convenience constructor for a Card object with a minimum number of inputs. - * * @param number the card number * @param expMonth the expiry month * @param expYear the expiry year * @param cvc the CVC code + * @deprecated public constructor will be removed in next version, use Card.create(number, expMonth, expYear,cvc) instead. + *

+ * Convenience constructor for a Card object with a minimum number of inputs. */ + @Deprecated public Card( String number, Integer expMonth, Integer expYear, String cvc) { + + this(number, expMonth, expYear, cvc, new HashMap<>()); + } + + /** + * @param number the card number + * @param expMonth the expiry month + * @param expYear the expiry year + * @param cvc the CVC code + * @param data additional data for name, zip, address, country, email + */ + private Card(String number, + Integer expMonth, + Integer expYear, + String cvc, + Map data) { this.number = MonriTextUtils.nullIfBlank(normalizeCardNumber(number)); this.expMonth = expMonth; this.expYear = expYear; this.cvc = MonriTextUtils.nullIfBlank(cvc); this.brand = getBrand(); this.last4 = MonriTextUtils.nullIfBlank(last4) == null ? getLast4() : last4; + this.data = data; + } + + /** + * Builder class for Card model + */ + public static class CardBuilder { + + private final Map data; + private final Card card; + + /** + * @param number the credit card number + * @param expMonth the expiry month, as an integer value between 1 and 12 + * @param expYear expiry year + * @param cvc the card CVC number + * @param data additional data for name, zip, address, country, email + */ + private CardBuilder(final String number, + final Integer expMonth, + final Integer expYear, + final String cvc, + Map data) { + + this.data = new HashMap<>(data); + this.card = new Card(number, expMonth, expYear, cvc); + } + + public CardBuilder name(final String name) { + this.data.put(AdditionalData.Constants.FULL_NAME, name); + return this; + } + + public String getName() { + return (String) data.get(AdditionalData.Constants.FULL_NAME); + } + + public CardBuilder address(final String address) { + this.data.put(AdditionalData.Constants.ADDRESS, address); + return this; + } + + public String getAddress() { + return (String) data.get(AdditionalData.Constants.ADDRESS); + } + + public String getCity() { + return (String) data.get(AdditionalData.Constants.CITY); + } + + public CardBuilder city(final String city) { + this.data.put(AdditionalData.Constants.CITY, city); + return this; + } + + public CardBuilder zip(final String zip) { + this.data.put(AdditionalData.Constants.ZIP, zip); + return this; + } + + public String getZip() { + return (String) data.get(AdditionalData.Constants.ZIP); + } + + public CardBuilder country(final String country) { + this.data.put(AdditionalData.Constants.COUNTRY, country); + return this; + } + + public String getCountry() { + return (String) data.get(AdditionalData.Constants.COUNTRY); + } + + public String getPhone() { + return (String) data.get(AdditionalData.Constants.PHONE); + } + + public CardBuilder phone(final String phone) { + this.data.put(AdditionalData.Constants.PHONE, phone); + return this; + } + + public CardBuilder email(final String email) { + this.data.put(AdditionalData.Constants.EMAIL, email); + return this; + } + + public String getEmail() { + return (String) data.get(AdditionalData.Constants.EMAIL); + } + + public CardBuilder data(Map data) { + this.data.put(AdditionalData.Constants.META_DATA, data); + return this; + } + + + public Map getData() { + //noinspection unchecked + return Collections.unmodifiableMap((Map) data.get(AdditionalData.Constants.META_DATA)); + } + + public boolean validate() { + + return card.validateCard(Calendar.getInstance()) && validateData(); + + } + + /** + * Checks whether or not additional data is valid + * + * @return 'true' if all additional data is valid, 'false' otherwise + */ + private boolean validateData() { + + for (String dataKey : data.keySet()) { + final Object dataValue = data.get(dataKey); + + if (dataValue == null) { + return false; + } + + final AdditionalData additionalData = AdditionalData.fromValue(dataKey); + + if (!additionalData.isValid(dataValue)) { + return false; + } + } + + return true; + + } + + /** + * @return Card instance + */ + public Card build() { + card.setData(this.data); + return card; + } + + } + + /** + * @return a CardBuilder populated with the fields of this Card instance + */ + public CardBuilder toBuilder() { + return new CardBuilder(number, expMonth, expYear, cvc, data); + } + + /** + * @param number the card number + * @param expMonth the expiry month + * @param expYear the expiry year + * @param cvc the CVC code + * @return Card object with populated fields + */ + public static Card create(final String number, + final Integer expMonth, + final Integer expYear, + final String cvc) { + return new CardBuilder(number, expMonth, expYear, cvc, new HashMap<>()) + .build(); + } + + private void setData(final Map data) { + this.data = data; + } + + public Map getData() { + return Collections.unmodifiableMap(data); } /** diff --git a/monri/src/main/java/com/monri/android/model/ModelUtils.java b/monri/src/main/java/com/monri/android/model/ModelUtils.java index e853da7..5763437 100644 --- a/monri/src/main/java/com/monri/android/model/ModelUtils.java +++ b/monri/src/main/java/com/monri/android/model/ModelUtils.java @@ -1,9 +1,9 @@ package com.monri.android.model; -import android.text.TextUtils; - import androidx.annotation.Nullable; +import com.monri.android.MonriTextUtils; + import java.util.Calendar; import java.util.Locale; @@ -19,7 +19,7 @@ public class ModelUtils { * @return {@code true} if the input value consists entirely of integers */ static boolean isWholePositiveNumber(@Nullable String value) { - return value != null && TextUtils.isDigitsOnly(value); + return value != null && MonriTextUtils.isDigitsOnly(value); } /** diff --git a/monri/src/main/java/com/monri/android/model/ValidationType.java b/monri/src/main/java/com/monri/android/model/ValidationType.java new file mode 100644 index 0000000..7298614 --- /dev/null +++ b/monri/src/main/java/com/monri/android/model/ValidationType.java @@ -0,0 +1,6 @@ +package com.monri.android.model; + +public enum ValidationType { + REGEX, + MAP_SIZE_VALIDATION +} diff --git a/monri/src/main/java/com/monri/android/view/CardMultilineWidget.java b/monri/src/main/java/com/monri/android/view/CardMultilineWidget.java index 787d66b..3f5c9e9 100644 --- a/monri/src/main/java/com/monri/android/view/CardMultilineWidget.java +++ b/monri/src/main/java/com/monri/android/view/CardMultilineWidget.java @@ -122,7 +122,8 @@ public Card getCard() { int[] cardDate = mExpiryDateEditText.getValidDateFields(); String cvcValue = mCvcEditText.getText().toString(); - Card card = new Card(cardNumber, cardDate[0], cardDate[1], cvcValue); + Card card = Card.create(cardNumber, cardDate[0], cardDate[1], cvcValue); + if (mShouldShowPostalCode) { // card.setAddressZip(mPostalCodeEditText.getText().toString()); } diff --git a/monri/src/test/java/com/monri/android/model/CardTest.java b/monri/src/test/java/com/monri/android/model/CardTest.java new file mode 100644 index 0000000..ac47eae --- /dev/null +++ b/monri/src/test/java/com/monri/android/model/CardTest.java @@ -0,0 +1,190 @@ +package com.monri.android.model; + + +import org.junit.Assert; +import org.junit.Test; + +import java.util.HashMap; +import java.util.Map; + +public class CardTest { + + private static Card createExampleCard() { + return Card.create("4111 1111 1111 1111", 12, 2024, "123"); + } + + + @Test + public void testBuilderDeepCopy() { + final Card card = createExampleCard(); + final Card card1 = card.toBuilder().country("BIH").build(); + final Card card2 = card1.toBuilder().zip("71000").build(); + + //card should have null ip and country + Assert.assertNull(card.getData().get(AdditionalData.COUNTRY.getFieldName())); + Assert.assertNull(card.getData().get(AdditionalData.ZIP.getFieldName())); + + //card1 should have only country set + Assert.assertNull(card1.getData().get(AdditionalData.ZIP.getFieldName())); + Assert.assertEquals("BIH", card1.getData().get(AdditionalData.COUNTRY.getFieldName())); + + //card2 should have zip and country + Assert.assertEquals("BIH", card2.getData().get(AdditionalData.COUNTRY.getFieldName())); + Assert.assertEquals("71000", card2.getData().get(AdditionalData.ZIP.getFieldName())); + } + + @Test(expected = UnsupportedOperationException.class) + public void testDataImmutable() { + final Card exampleCard = createExampleCard(); + exampleCard.getData().put(AdditionalData.ZIP.getFieldName(), "71000"); + + Assert.assertNull(exampleCard.getData().get(AdditionalData.ZIP.getFieldName())); + } + + @Test + public void validName() { + final Card exampleCard = createExampleCard(); + final String name = "Adnan"; + + final Card modifiedCard = exampleCard.toBuilder().name(name).build(); + + Assert.assertNull(exampleCard.getData().get(AdditionalData.FULL_NAME.getFieldName())); + Assert.assertEquals(name, modifiedCard.getData().get(AdditionalData.FULL_NAME.getFieldName())); + + Assert.assertTrue(modifiedCard.toBuilder().validate()); + Assert.assertTrue(exampleCard.validateCard()); + Assert.assertTrue(modifiedCard.validateCard()); + + } + + @Test + public void validAddress() { + final Card exampleCard = createExampleCard(); + final String address = "Sabita Užičanina br. 17"; + + final Card modifiedCard = exampleCard.toBuilder() + .address(address) + .build(); + + Assert.assertEquals(address, modifiedCard.getData().get(AdditionalData.ADDRESS.getFieldName())); + Assert.assertNull(exampleCard.getData().get(AdditionalData.ADDRESS.getFieldName())); + + Assert.assertTrue(modifiedCard.toBuilder().validate()); + Assert.assertTrue(exampleCard.validateCard()); + Assert.assertTrue(modifiedCard.validateCard()); + } + + @Test + public void validCity() { + final Card exampleCard = createExampleCard(); + final String city = "Skoplje"; + + final Card modifiedCard = exampleCard.toBuilder() + .city(city) + .build(); + + Assert.assertEquals(city, modifiedCard.getData().get(AdditionalData.CITY.getFieldName())); + Assert.assertNull(exampleCard.getData().get(AdditionalData.CITY.getFieldName())); + + Assert.assertTrue(modifiedCard.toBuilder().validate()); + Assert.assertTrue(exampleCard.validateCard()); + Assert.assertTrue(modifiedCard.validateCard()); + + } + + @Test + public void validZip() { + final Card exampleCard = createExampleCard(); + final String zip = "10040"; + + final Card modifiedCard = exampleCard.toBuilder() + .zip(zip) + .build(); + + Assert.assertTrue(modifiedCard.toBuilder().validate()); + Assert.assertEquals(zip, modifiedCard.getData().get(AdditionalData.ZIP.getFieldName())); + Assert.assertNull(exampleCard.getData().get(AdditionalData.ZIP.getFieldName())); + + } + + @Test + public void validPhone() { + final Card exampleCard = createExampleCard(); + final String phoneNumber = "+38763 589-521"; + + final Card modifiedCard = exampleCard.toBuilder() + .phone(phoneNumber) + .build(); + + Assert.assertEquals(phoneNumber, modifiedCard.getData().get(AdditionalData.PHONE.getFieldName())); + Assert.assertNull(exampleCard.getData().get(AdditionalData.PHONE.getFieldName())); + + Assert.assertTrue(modifiedCard.toBuilder().validate()); + Assert.assertTrue(exampleCard.validateCard()); + Assert.assertTrue(modifiedCard.validateCard()); + + } + + @Test + public void validEmail() { + final Card exampleCard = createExampleCard(); + final String email = "monri@monri.com"; + + final Card modifiedCard = exampleCard.toBuilder() + .email(email) + .build(); + + Assert.assertEquals(email, modifiedCard.getData().get(AdditionalData.EMAIL.getFieldName())); + Assert.assertNull(exampleCard.getData().get(AdditionalData.EMAIL.getFieldName())); + + Assert.assertTrue(modifiedCard.toBuilder().validate()); + Assert.assertTrue(exampleCard.validateCard()); + Assert.assertTrue(modifiedCard.validateCard()); + + } + + @Test + public void validData() { + final Card exampleCard = createExampleCard(); + final Map testData = new HashMap(){{ + put("vip","true"); + put("business","yea"); + }}; + + + final Card modifiedCard = exampleCard.toBuilder() + .data(testData) + .build(); + + Assert.assertTrue(modifiedCard.toBuilder().validate()); + Assert.assertTrue(exampleCard.validateCard()); + Assert.assertTrue(modifiedCard.validateCard()); + + } + + @Test + public void validAllData() { + final Card exampleCard = createExampleCard(); + final boolean isDataValid = exampleCard.toBuilder() + .name("Adnan") + .address("Zagrebačka br. 19") + .city("Sarajevo") + .zip("71000") + .phone("063722982") + .email("zbregov@protein.com") + .validate(); + + final boolean isCardValidAfterAddingData = exampleCard.validateCard(); + + Assert.assertTrue(isCardValidAfterAddingData); + Assert.assertTrue(isDataValid); + + Assert.assertTrue(exampleCard.validateCard()); + } + + @Test + public void validateCard() { + final Card exampleCard = createExampleCard(); + Assert.assertTrue(exampleCard.validateCard()); + } +} \ No newline at end of file