diff --git a/CHANGELOG.md b/CHANGELOG.md
index 58814f2..abf0559 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,16 @@
## [Unreleased]
+## [0.2.0] - 2024-11-22
+
+### Fixed
+
+- Fixed Base64 decoding on Android by replacing the Apache Commons Codec dependency.
+
+### Changed
+
+- Internal dependency version bumps.
+
## [0.1.0] - 2024-02-02
### Fixed
@@ -58,7 +68,9 @@
## [0.0.1] - 2021-02-17
-[Unreleased]: https://github.com/saasquatch/saasquatch-java-sdk/compare/0.1.0...HEAD
+[Unreleased]: https://github.com/saasquatch/saasquatch-java-sdk/compare/0.2.0...HEAD
+
+[0.2.0]: https://github.com/saasquatch/saasquatch-java-sdk/compare/0.1.0...0.2.0
[0.1.0]: https://github.com/saasquatch/saasquatch-java-sdk/compare/0.0.5...0.1.0
diff --git a/README.md b/README.md
index b9988e9..b990d96 100644
--- a/README.md
+++ b/README.md
@@ -42,7 +42,7 @@ Add the dependency:
com.github.saasquatch
saasquatch-java-sdk
- 0.1.0
+ 0.2.0
```
@@ -50,13 +50,13 @@ Add the dependency:
```gradle
dependencies {
- implementation 'com.github.saasquatch:saasquatch-java-sdk:0.1.0'
+ implementation 'com.github.saasquatch:saasquatch-java-sdk:0.2.0'
}
```
For more information and other built tools, [please refer to the JitPack page](https://jitpack.io/#saasquatch/saasquatch-java-sdk).
-This library aims to abstract away the I/O layer and [Reactive Streams](https://www.reactive-streams.org/) implementations to be implementation agnostic. As of right now, this library depends on [RxJava 3](https://github.com/ReactiveX/RxJava), [Gson](https://github.com/google/gson), and [Apache HttpClient 5](https://hc.apache.org/httpcomponents-client-5.0.x/index.html), but never exposes library-specific interfaces other than Reactive Streams interfaces. **It is recommended that you explicitly import the transitive dependencies if you intend to use them**, since we may upgrade or switch to other I/O or Reactive Streams libraries in the future.
+This library aims to abstract away the I/O layer and [Reactive Streams](https://www.reactive-streams.org/) implementations to be implementation agnostic. As of right now, this library depends on [RxJava 3](https://github.com/ReactiveX/RxJava), [Gson](https://github.com/google/gson), and [Apache HttpClient 5](https://hc.apache.org/httpcomponents-client-5.0.x/index.html), but never exposes library-specific interfaces other than Reactive Streams interfaces. **It is recommended that you explicitly import the transitive dependencies if you intend to use them directly**, since we may upgrade or switch to other I/O or Reactive Streams libraries in the future.
### Android
diff --git a/build.gradle b/build.gradle
index d376026..373b51f 100644
--- a/build.gradle
+++ b/build.gradle
@@ -11,20 +11,27 @@ tasks.javadoc.options.encoding = 'UTF-8'
repositories {
mavenCentral()
- maven { url = uri('https://jitpack.io') }
}
dependencies {
api 'org.reactivestreams:reactive-streams:1.0.4'
implementation 'com.google.code.findbugs:jsr305:3.0.2'
- implementation 'commons-codec:commons-codec:1.16.0'
- implementation 'org.apache.httpcomponents.client5:httpclient5:5.3.1'
- implementation 'io.reactivex.rxjava3:rxjava:3.1.8'
+ /*
+ * This is a lightweight Base64 implementation that works on both Android and the regular JRE.
+ * java.util.Base64 is not available on earlier versions of Android.
+ * android.util.Base64 does not work on regular JRE.
+ * Commons codec should be avoided because Android comes bundled with an early version.
+ * Apache client5 comes with a Base64 class but it's considered internal API.
+ */
+ implementation 'net.iharder:base64:2.3.9'
+ implementation 'org.apache.httpcomponents.client5:httpclient5:5.4.1'
+ implementation 'io.reactivex.rxjava3:rxjava:3.1.9'
+ // Version 2.11.0 requires Android API level 21
implementation 'com.google.code.gson:gson:2.10.1'
- testImplementation 'org.junit.jupiter:junit-jupiter-api:5.10.1'
- testImplementation 'org.junit.jupiter:junit-jupiter-engine:5.10.1'
- testImplementation 'com.google.guava:guava:33.0.0-jre'
- testImplementation 'io.projectreactor:reactor-core:3.6.2'
+ testImplementation 'org.junit.jupiter:junit-jupiter-api:5.11.3'
+ testImplementation 'org.junit.jupiter:junit-jupiter-engine:5.11.3'
+ testImplementation 'com.google.guava:guava:33.3.1-jre'
+ testImplementation 'io.projectreactor:reactor-core:3.7.0'
}
tasks.test {
diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar
index d64cd49..a4b76b9 100644
Binary files a/gradle/wrapper/gradle-wrapper.jar and b/gradle/wrapper/gradle-wrapper.jar differ
diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
index 1af9e09..94113f2 100644
--- a/gradle/wrapper/gradle-wrapper.properties
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
-distributionUrl=https\://services.gradle.org/distributions/gradle-8.5-bin.zip
+distributionUrl=https\://services.gradle.org/distributions/gradle-8.11-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
diff --git a/gradlew b/gradlew
index 1aa94a4..f5feea6 100755
--- a/gradlew
+++ b/gradlew
@@ -15,6 +15,8 @@
# See the License for the specific language governing permissions and
# limitations under the License.
#
+# SPDX-License-Identifier: Apache-2.0
+#
##############################################################################
#
@@ -55,7 +57,7 @@
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
-# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
+# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
@@ -84,7 +86,8 @@ done
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
-APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit
+APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s
+' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
diff --git a/gradlew.bat b/gradlew.bat
index 6689b85..9b42019 100644
--- a/gradlew.bat
+++ b/gradlew.bat
@@ -13,6 +13,8 @@
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
+@rem SPDX-License-Identifier: Apache-2.0
+@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@@ -43,11 +45,11 @@ set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
-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.
+echo. 1>&2
+echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
+echo. 1>&2
+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
+echo location of your Java installation. 1>&2
goto fail
@@ -57,11 +59,11 @@ set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
-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.
+echo. 1>&2
+echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
+echo. 1>&2
+echo Please set the JAVA_HOME variable in your environment to match the 1>&2
+echo location of your Java installation. 1>&2
goto fail
diff --git a/src/main/java/com/saasquatch/sdk/auth/AuthMethod.java b/src/main/java/com/saasquatch/sdk/auth/AuthMethod.java
index 8e15498..eff3752 100644
--- a/src/main/java/com/saasquatch/sdk/auth/AuthMethod.java
+++ b/src/main/java/com/saasquatch/sdk/auth/AuthMethod.java
@@ -30,7 +30,6 @@ static AuthMethod noAuth() {
/**
* Basic authentication with username and password
*/
- @Beta
static AuthMethod ofBasic(@Nonnull String username, @Nonnull String password) {
return new BasicAuth(Objects.requireNonNull(username, "username"),
Objects.requireNonNull(password, "password"));
diff --git a/src/main/java/com/saasquatch/sdk/auth/BasicAuth.java b/src/main/java/com/saasquatch/sdk/auth/BasicAuth.java
index d8c017f..39df44f 100644
--- a/src/main/java/com/saasquatch/sdk/auth/BasicAuth.java
+++ b/src/main/java/com/saasquatch/sdk/auth/BasicAuth.java
@@ -1,7 +1,8 @@
package com.saasquatch.sdk.auth;
import static java.nio.charset.StandardCharsets.UTF_8;
-import org.apache.commons.codec.binary.Base64;
+
+import net.iharder.Base64;
import org.apache.hc.client5.http.async.methods.SimpleRequestBuilder;
import org.apache.hc.core5.http.HttpHeaders;
@@ -18,7 +19,7 @@ final class BasicAuth implements AuthMethod {
@Override
public void mutateRequest(SimpleRequestBuilder requestBuilder) {
requestBuilder.setHeader(HttpHeaders.AUTHORIZATION,
- "Basic " + Base64.encodeBase64String((username + ':' + password).getBytes(UTF_8)));
+ "Basic " + Base64.encodeBytes((username + ':' + password).getBytes(UTF_8)));
}
}
diff --git a/src/main/java/com/saasquatch/sdk/internal/InternalUtils.java b/src/main/java/com/saasquatch/sdk/internal/InternalUtils.java
index 0c5d9c9..22ca122 100644
--- a/src/main/java/com/saasquatch/sdk/internal/InternalUtils.java
+++ b/src/main/java/com/saasquatch/sdk/internal/InternalUtils.java
@@ -3,9 +3,7 @@
import static com.saasquatch.sdk.internal.json.GsonUtils.gson;
import static java.nio.charset.StandardCharsets.UTF_8;
-import com.google.gson.JsonElement;
-import com.google.gson.JsonObject;
-import com.google.gson.JsonParser;
+import com.google.gson.JsonSyntaxException;
import com.saasquatch.sdk.exceptions.SaaSquatchApiException;
import com.saasquatch.sdk.exceptions.SaaSquatchUnhandledApiException;
import com.saasquatch.sdk.input.UserIdInput;
@@ -20,6 +18,7 @@
import java.io.InputStream;
import java.io.UnsupportedEncodingException;
import java.net.URLDecoder;
+import java.net.URLEncoder;
import java.nio.CharBuffer;
import java.nio.charset.Charset;
import java.util.AbstractMap.SimpleImmutableEntry;
@@ -42,8 +41,7 @@
import javax.annotation.Nonnull;
import javax.annotation.Nullable;
import javax.annotation.WillNotClose;
-import org.apache.commons.codec.binary.Base64;
-import org.apache.commons.codec.net.URLCodec;
+import net.iharder.Base64;
import org.apache.hc.client5.http.async.methods.SimpleHttpRequest;
import org.apache.hc.client5.http.async.methods.SimpleHttpResponse;
import org.apache.hc.client5.http.impl.async.CloseableHttpAsyncClient;
@@ -244,7 +242,12 @@ public static Map unmodifiableMap(@Nonnull Map map) {
* RFC3986 URL encode
*/
public static String urlEncode(@Nonnull String s) {
- return new String(URLCodec.encodeUrl(RFC_3986_SAFE_CHARS, s.getBytes(UTF_8)), UTF_8);
+ try {
+ return URLEncoder.encode(s, UTF_8.name())
+ .replace("+", "%20").replace("*", "%2A").replace("%7E", "~");
+ } catch (UnsupportedEncodingException e) {
+ throw new RuntimeException(e); // Won't happen
+ }
}
/**
@@ -362,13 +365,23 @@ public static Object getNestedMapValue(Map map, String... keys)
if (result == null) {
return null;
}
- @SuppressWarnings("unchecked") final Map resultAsMap =
- (Map) result;
- result = resultAsMap.get(key);
+ //noinspection unchecked
+ result = ((Map) result).get(key);
}
return result;
}
+ public static String addBase64Padding(String base64) {
+ if (base64.length() % 4 == 0) {
+ return base64; // Avoid unnecessary copying with StringBuilder
+ }
+ final StringBuilder base64Builder = new StringBuilder(base64);
+ do {
+ base64Builder.append('=');
+ } while (base64Builder.length() % 4 != 0);
+ return base64Builder.toString();
+ }
+
/**
* Extract the payload as a JSON object. This method does NOT do a full JWT validation.
*/
@@ -378,15 +391,19 @@ public static Map getJwtPayload(String jwt) {
throw new IllegalArgumentException("Invalid JWT");
}
final String payloadPart = jwtParts[1];
- // Do not use the overload that takes a String. It does not work on Android.
- final byte[] payloadBytes = Base64.decodeBase64(payloadPart.getBytes(UTF_8));
- final JsonElement jsonElement = JsonParser.parseString(new String(payloadBytes, UTF_8));
- if (!(jsonElement instanceof JsonObject)) {
- throw new IllegalArgumentException("JWT payload is not a JSON object");
+ final byte[] payloadBytes;
+ // This Base64 library expects the base64 string to have proper padding
+ try {
+ payloadBytes = Base64.decode(addBase64Padding(payloadPart), Base64.URL_SAFE);
+ } catch (IOException e) {
+ throw new IllegalArgumentException("Invalid JWT payload", e);
+ }
+ try {
+ //noinspection unchecked
+ return gson.fromJson(new String(payloadBytes, UTF_8), Map.class);
+ } catch (JsonSyntaxException e) {
+ throw new IllegalArgumentException("Invalid JWT payload", e);
}
- @SuppressWarnings("unchecked") final Map payloadMap =
- gson.fromJson(jsonElement, Map.class);
- return payloadMap;
}
@Nonnull
diff --git a/src/test/java/com/saasquatch/sdk/InternalUtilsTest.java b/src/test/java/com/saasquatch/sdk/InternalUtilsTest.java
index aa98e6d..95ecb06 100644
--- a/src/test/java/com/saasquatch/sdk/InternalUtilsTest.java
+++ b/src/test/java/com/saasquatch/sdk/InternalUtilsTest.java
@@ -1,5 +1,6 @@
package com.saasquatch.sdk;
+import static com.saasquatch.sdk.internal.InternalUtils.addBase64Padding;
import static com.saasquatch.sdk.internal.InternalUtils.defaultIfNull;
import static com.saasquatch.sdk.internal.InternalUtils.entryOf;
import static com.saasquatch.sdk.internal.InternalUtils.getJwtPayload;
@@ -8,6 +9,7 @@
import static com.saasquatch.sdk.internal.InternalUtils.isBlank;
import static com.saasquatch.sdk.internal.InternalUtils.requireNotBlank;
import static com.saasquatch.sdk.internal.InternalUtils.unmodifiableList;
+import static com.saasquatch.sdk.internal.InternalUtils.urlEncode;
import static com.saasquatch.sdk.internal.json.GsonUtils.gson;
import static java.nio.charset.StandardCharsets.UTF_8;
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
@@ -111,6 +113,11 @@ public void testUnmodifiableList() {
assertEquals("SingletonList", unmodifiableList(Arrays.asList(1)).getClass().getSimpleName());
}
+ @Test
+ public void testUrlEncode() throws Exception {
+ assertEquals("foo%20%2B%2A~bar", urlEncode("foo +*~bar"));
+ }
+
@Test
public void testRequireNotBlank() {
assertThrows(NullPointerException.class, () -> requireNotBlank(null, ""));
@@ -162,6 +169,16 @@ public void testGetNestedMapValue() {
}
}
+ @Test
+ public void testBase64Padding() {
+ assertEquals("", addBase64Padding(""));
+ assertEquals("a===", addBase64Padding("a"));
+ assertEquals("aa==", addBase64Padding("aa"));
+ assertEquals("aaa=", addBase64Padding("aaa"));
+ assertEquals("aaaa", addBase64Padding("aaaa"));
+ assertEquals("aaaaa===", addBase64Padding("aaaaa"));
+ }
+
@Test
public void testGetJwtPayload() {
//noinspection ConstantConditions
@@ -173,6 +190,13 @@ public void testGetJwtPayload() {
assertEquals(ImmutableMap.of(), getJwtPayload("a.e30.c"));
}
+ @Test
+ public void testGetJwtPayloadWithPadding() {
+ final Map map = assertDoesNotThrow(() -> getJwtPayload(
+ "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6IklSWThGZHI2aXVpVzU2NTI1NTVyclk2TG9hUFVKQzg0WjEifQ.eyJ1c2VyIjp7ImVtYWlsIjoiZm9vQGV4YW1wbGUuY29tIiwiZmlyc3ROYW1lIjoiRklSU1ROQU1FIiwiaWQiOiJhYWEiLCJhY2NvdW50SWQiOiJhYWEiLCJsYXN0TmFtZSI6ImFhYSJ9fQ.6XoB_nYJy2Vged-Dm9yuf2X9t8XqfqZDQziby0jIDeY"));
+ assertEquals("FIRSTNAME", getNestedMapValue(map, "user", "firstName"));
+ }
+
@Test
public void testJwtToUserIdInput() {
final UserIdInput userIdInput = getUserIdInputFromUserJwt(