diff --git a/.github/jitpack-publish.init.gradle b/.github/jitpack-publish.init.gradle new file mode 100644 index 0000000..5d92c83 --- /dev/null +++ b/.github/jitpack-publish.init.gradle @@ -0,0 +1,21 @@ +// Mirrors the maven-publish injection JitPack performs, so CI can rehearse a release. +// singleVariant must be set as the plugin applies; the component exists only after evaluation. +allprojects { project -> + project.plugins.withId('com.android.library') { + project.android.publishing { singleVariant('release') } + project.apply plugin: 'maven-publish' + + project.afterEvaluate { + project.publishing { + publications { + release(MavenPublication) { + from project.components.release + groupId = project.findProperty('group') + artifactId = 'android-sdk' + version = project.findProperty('version') + } + } + } + } + } +} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..aae6e3c --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,139 @@ +name: CI + +on: + workflow_dispatch: + pull_request: + push: + branches: [main, short-io-sdk] + +# Least privilege: nothing here needs more than reading the checkout. +permissions: + contents: read + +# Cancel superseded PR runs; branch pushes finish so the badge never shows a cancellation. +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +env: + # AGP 8.10 requires 17. Keep in step with jitpack.yml. + JAVA_VERSION: "17" + +jobs: + unit-test: + name: unit tests + runs-on: ubuntu-latest + # Otherwise a wedged daemon runs to the 360-minute default. + timeout-minutes: 20 + steps: + - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + with: + persist-credentials: false + + - uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1 + with: + distribution: temurin + java-version: ${{ env.JAVA_VERSION }} + + - uses: gradle/actions/setup-gradle@ac638b010cf58a27ee6c972d7336334ccaf61c96 # v4.4.1 + + # Robolectric caches android-all jars outside ~/.gradle, so they need their own key. + - name: Cache Robolectric runtimes + uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4 + with: + path: ~/.m2/repository/org/robolectric + key: robolectric-${{ runner.os }}-${{ hashFiles('**/gradle/libs.versions.toml', '**/src/test/resources/robolectric.properties') }} + restore-keys: robolectric-${{ runner.os }}- + + - name: Run unit tests + run: ./gradlew --no-daemon testDebugUnitTest + + # Lint ships with AGP and this module is clean, so any finding is a regression. + - name: Lint + run: ./gradlew --no-daemon lint + + - name: Upload reports + if: failure() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: unit-test-reports + path: | + ShortIOSDK/build/reports/tests/ + ShortIOSDK/build/reports/lint-results-*.html + ShortIOSDK/build/test-results/ + retention-days: 7 + + # Type-checks androidTest without an emulator. + instrumentation-compile: + name: compile instrumented tests + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + with: + persist-credentials: false + + - uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1 + with: + distribution: temurin + java-version: ${{ env.JAVA_VERSION }} + + - uses: gradle/actions/setup-gradle@ac638b010cf58a27ee6c972d7336334ccaf61c96 # v4.4.1 + + - name: Assemble instrumented test APK + run: ./gradlew --no-daemon assembleAndroidTest + + # JitPack publishes with -xtest -xlint, so this is the only place a broken + # publish surfaces before tag time. + jitpack-publish: + name: jitpack publish rehearsal + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + with: + persist-credentials: false + + - uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1 + with: + distribution: temurin + java-version: ${{ env.JAVA_VERSION }} + + - uses: gradle/actions/setup-gradle@ac638b010cf58a27ee6c972d7336334ccaf61c96 # v4.4.1 + + - name: Publish to a local repository + run: | + ./gradlew --no-daemon -I .github/jitpack-publish.init.gradle clean \ + -Pgroup=com.github.Short-io \ + -Pversion=ci-${{ github.sha }} \ + -xtest -xlint \ + publishToMavenLocal + + - name: Verify the AAR and POM were produced + run: | + set -euo pipefail + DIR=~/.m2/repository/com/github/Short-io/android-sdk/ci-${{ github.sha }} + if [ ! -d "$DIR" ]; then + echo "::error::Nothing was published to $DIR" + find ~/.m2/repository/com/github -maxdepth 4 -type d || true + exit 1 + fi + ls -l "$DIR" + test -n "$(find "$DIR" -name '*.aar' -print -quit)" \ + || { echo "::error::No AAR in the published artifacts."; exit 1; } + test -n "$(find "$DIR" -name '*.pom' -print -quit)" \ + || { echo "::error::No POM in the published artifacts."; exit 1; } + + # The ruleset requires a check named `test`; the jobs above report under their + # own names. always() so a failed job still reports this check. + test: + name: test + needs: [unit-test, instrumentation-compile, jitpack-publish] + if: always() + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - run: | + [ "${{ needs.unit-test.result }}" = "success" ] \ + && [ "${{ needs.instrumentation-compile.result }}" = "success" ] \ + && [ "${{ needs.jitpack-publish.result }}" = "success" ] diff --git a/.gitignore b/.gitignore index aa724b7..06500f5 100644 --- a/.gitignore +++ b/.gitignore @@ -1,15 +1,11 @@ *.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 +.kotlin +.idea diff --git a/README.md b/README.md index e43a543..a9164f5 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,8 @@ # ShortIOSDK for Android +[![CI](https://github.com/Short-io/android-sdk/actions/workflows/ci.yml/badge.svg)](https://github.com/Short-io/android-sdk/actions/workflows/ci.yml) + This SDK allows you to create short links using the [Short.io](https://short.io/) API based on a public API key and custom parameters. It also supports Android deep linking integration. ## ✨ Features @@ -52,7 +54,7 @@ It will be: ```kotlin dependencies { implementation("com.github.User:Repo:Tag") // Example - implementation("com.github.Short-io:android-sdk:v1.0.4") // Use this + implementation("com.github.Short-io:android-sdk:v1.0.9") // Use this } ``` ### 3. Sync the Project @@ -80,6 +82,27 @@ import com.github.shortiosdk.ShortioSdk ### πŸ”— SDK Usage +#### Initialize the SDK + +To start using ShortioSdk, you need to initialize it early in your app lifecycle, preferably in your Activity's onCreate() method or in your custom Application class. + +Example: Initialize in Activity + +```kotlin +override fun onCreate() { + super.onCreate() + ShortioSdk.initialize(apiKey, domain) +} +``` +* apiKey: Your API key string for SDK initialization. +* domain: The default domain to use for URL shortening. + +### πŸ’‘ How It Works + +The app demonstrates: + +#### βœ… Generating Short Links + ```kotlin import com.github.shortiosdk.ShortioSdk import com.github.shortiosdk.ShortIOParameters @@ -87,7 +110,6 @@ import com.github.shortiosdk.ShortIOResult try { val params = ShortIOParameters( - domain = "your_domain", // Replace with your Short.io domain originalURL = "your_originalURL" // Replace with your Short.io domain ) } catch (e: Exception) { @@ -95,20 +117,19 @@ try { } ``` -**Note**: Both `domain` and `originalURL` are the required parameters. You can also pass optional parameters such as `path`, `title`, `utmParameters`, etc. +**Note**: Only the `originalURL` is the required parameter as `domain` is passed in the initialize method of SDK. You can also pass optional parameters such as `path`, `title`, `utmParameters`, etc. ```kotlin -val apiKey = "your_public_apiKey" // Replace with your Short.io Public API Key thread { try { - when (val result = ShortioSdk.shortenUrl(apiKey, params)) { + when (val result = ShortioSdk.createShortLink(params)) { is ShortIOResult.Success -> { - println("Shortened URL: ${result.data.shortURL}") + Log.d("ShortIOResult","Shortened URL: ${result.data.shortURL}") } is ShortIOResult.Error -> { val error = result.data - println("Error ${error.statusCode}: ${error.message} (code: ${error.code})") + Log.d("ShortIOResult","Error ${error.statusCode}: ${error.message} (code: ${error.code})") } } } catch (e: Exception) { @@ -116,6 +137,8 @@ thread { } } ``` +**Note**: Deprecated: `createShortLink(params, apiKey)` is still supported for backward compatibility but is no longer recommended for use. Use `createShortLink(params)` instead. + ## πŸ“„ API Parameters The `ShortIOParameters` struct is used to define the details of the short link you want to create. Below are the available parameters: @@ -123,8 +146,8 @@ The `ShortIOParameters` struct is used to define the details of the short link y | Parameter | Type | Required | Description | | ------------------- | ----------- | -------- | ------------------------------------------------------------ | -| `domain` | `String` | βœ… | Your Short.io domain (e.g., `example.short.gy`) | -| `originalURL` | `String` | βœ… | The original URL to be shortened | +| `domain` | `String?` | ❌ (Deprecated) | Your Short.io domain (e.g., `example.short.gy`). ⚠️ Deprecated. Optional β€” when null or blank, the domain passed to `initialize()` is used. | +| `originalURL` | `String` | βœ… | The original URL to be shortened | | `cloaking` | `Boolean` | ❌ | If `true`, hides the destination URL from the user | | `password` | `String` | ❌ | Password to protect the short link | | `redirectType` | `Int` | ❌ | Type of redirect (e.g., 301, 302) | @@ -241,7 +264,7 @@ keytool -list -v -keystore ~/.android/debug.keystore -alias androiddebugkey -sto 3. Tap on **β€œAdd link”** under the **Open by Default** section. -4. Add your URL and make sure to enable the checkbox for your link. +4. Add your URL if not added and make sure to enable the checkbox for your link. ### πŸ”— Step 4: Open the App Using a Deep Link @@ -253,6 +276,8 @@ keytool -list -v -keystore ~/.android/debug.keystore -alias androiddebugkey -sto ### 🧭 Step 5: Handle Incoming URLs with onNewIntent() Method +To retrieve the original URL from Short.io links in your Android app, you can handle incoming intents in onNewIntent(), which allows your activity to process links that are opened while it is already running. + 1. Open your main activity file (e.g., MainActivity.kt). 2. Override the `onNewIntent()` method to receive new intents when the activity is already running: @@ -260,23 +285,67 @@ keytool -list -v -keystore ~/.android/debug.keystore -alias androiddebugkey -sto ```kotlin override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) - val result = ShortioSdk.handleIntent(intent) - Log.d("New Intent", "Host: ${result?.host}, Path: ${result?.path}") + lifecycleScope.launch { + val result = ShortioSdk.handleIntent(intent) + // Access the original URL + val originalUrl = result?.destinationUrl + Log.d("New Intent", + "Host: ${result?.host}, + Path: ${result?.path}, + Original URL: $originalUrl" + ) + } } ``` -3. In the same activity, also handle the initial intent inside the `onCreate()` method: +3. In the same activity, To handle the initial intent inside the `onCreate()` method: ```kotlin override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + lifecycleScope.launch { val result = ShortioSdk.handleIntent(intent) - Log.d("New Intent", "Host: ${result?.host}, Path: ${result?.path}") + // Access the original URL + val originalUrl = result?.destinationUrl + Log.d("New Intent", + "Host: ${result?.host}, + Path: ${result?.path}, + Original URL: $originalUrl" + ) + } } ``` +### πŸ” Secure Short Link + +If you want to encrypt the original URL before shortening it. For privacy or security reasons β€” the SDK provides a utility function called createSecure. This function encrypts the original URL using AES-GCM and returns a secured URL with a separate decryption key. + +```kotlin +val originalURL = "your_original_URL" +val result = ShortioSdk.createSecure(originalURL) +Log.d("SecureURL", "RESULT: ${result}") +Log.d("securedOriginalURL", "URL: ${result.securedOriginalURL}") +Log.d("securedShortUrl", "URL: ${result.securedShortUrl}") +``` + +### πŸ”„ Conversion Tracking + +Track conversions for your short links to measure campaign effectiveness. The SDK provides a simple method to record conversions. + +```kotlin +CoroutineScope(Dispatchers.IO).launch { + // All three are optional. clid falls back to the one captured by handleIntent, + // domain to the one passed to initialize(). Returns false if tracking failed. + val res = ShortioSdk.trackConversion( + clid = "your_clid", + domain = "example.short.gy", + conversionId = "purchase" // e.g. 'signup', 'purchase', 'download' + ) + Log.d("Conversion Tracking", "tracked: $res") +} +``` -### βœ… Final Checklist +### βœ… Final Checklist for Deep Linking * App is signed with the correct keystore. diff --git a/ShortIOSDK/build.gradle.kts b/ShortIOSDK/build.gradle.kts index c3d0d7c..1e973c9 100644 --- a/ShortIOSDK/build.gradle.kts +++ b/ShortIOSDK/build.gradle.kts @@ -30,6 +30,11 @@ android { kotlinOptions { jvmTarget = "11" } + testOptions { + unitTests { + isIncludeAndroidResources = true + } + } } dependencies { @@ -40,6 +45,9 @@ dependencies { implementation("com.squareup.okhttp3:okhttp:4.12.0") implementation("com.google.code.gson:gson:2.10.1") testImplementation(libs.junit) + testImplementation(libs.robolectric) + testImplementation(libs.mockwebserver) + testImplementation(libs.kotlinx.coroutines.test) androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.espresso.core) -} \ No newline at end of file +} diff --git a/ShortIOSDK/src/main/java/com/github/shortiosdk/Constants.kt b/ShortIOSDK/src/main/java/com/github/shortiosdk/Constants.kt index 1853fea..fbaffa2 100644 --- a/ShortIOSDK/src/main/java/com/github/shortiosdk/Constants.kt +++ b/ShortIOSDK/src/main/java/com/github/shortiosdk/Constants.kt @@ -1,3 +1,6 @@ package com.github.shortiosdk -val shortenUrl = "https://api.short.io/links/public" \ No newline at end of file +val baseURL = "https://api.short.io/links/public" + +@Deprecated("Renamed to baseURL", ReplaceWith("baseURL")) +val shortenUrl = baseURL diff --git a/ShortIOSDK/src/main/java/com/github/shortiosdk/Helpers/HelperMethods.kt b/ShortIOSDK/src/main/java/com/github/shortiosdk/Helpers/HelperMethods.kt index eda18fe..8c9ffd6 100644 --- a/ShortIOSDK/src/main/java/com/github/shortiosdk/Helpers/HelperMethods.kt +++ b/ShortIOSDK/src/main/java/com/github/shortiosdk/Helpers/HelperMethods.kt @@ -1,32 +1,30 @@ package com.github.shortiosdk.Helpers -import okhttp3.OkHttpClient -import okhttp3.Request +import android.net.Uri +import android.util.Log -fun HandleClick(uri: String): String? { - val client = OkHttpClient() - - val url = when { - uri.contains("utm_medium=android", ignoreCase = true) -> uri - uri.contains("?") -> "$uri&utm_medium=android" - else -> "$uri?utm_medium=android" +fun extractClidFromUrl(urlString: String): String? { + return try { + val uri = Uri.parse(urlString) + uri.getQueryParameter("clid") + } catch (e: Exception) { + Log.e("ShortioSdk", "could not read clid from $urlString", e) + null } +} - val request = Request.Builder() - .url(url) - .addHeader("accept", "application/json") - .build() +fun removeUtmParams(url: String): String { + val uri = Uri.parse(url) + val builder = uri.buildUpon().clearQuery() - return try { - client.newCall(request).execute().use { response -> - if (response.isSuccessful) { - response.code.toString() - } else { - "Link is not Valid" + uri.queryParameterNames + .filter { !it.startsWith("utm_", ignoreCase = true) } + .forEach { key -> + uri.getQueryParameters(key)?.forEach { value -> + builder.appendQueryParameter(key, value) } } - } catch (e: Exception) { - e.toString() - } -} \ No newline at end of file + + return builder.build().toString() +} diff --git a/ShortIOSDK/src/main/java/com/github/shortiosdk/Model/ShortIOParameters.kt b/ShortIOSDK/src/main/java/com/github/shortiosdk/Model/ShortIOParameters.kt index 349e214..e34b3dd 100644 --- a/ShortIOSDK/src/main/java/com/github/shortiosdk/Model/ShortIOParameters.kt +++ b/ShortIOSDK/src/main/java/com/github/shortiosdk/Model/ShortIOParameters.kt @@ -1,6 +1,5 @@ package com.github.shortiosdk - data class ShortIOParameters( val originalURL: String, val cloaking: Boolean? = null, @@ -30,7 +29,7 @@ data class ShortIOParameters( val integrationFB: String? = null, var integrationGA: String? = null, val integrationGTM: String? = null, - val domain: String, + var domain: String? = null, val folderId: String? = null ) { init { diff --git a/ShortIOSDK/src/main/java/com/github/shortiosdk/Model/ShortIOResult.kt b/ShortIOSDK/src/main/java/com/github/shortiosdk/Model/ShortIOResult.kt index ebe9574..51aff88 100644 --- a/ShortIOSDK/src/main/java/com/github/shortiosdk/Model/ShortIOResult.kt +++ b/ShortIOSDK/src/main/java/com/github/shortiosdk/Model/ShortIOResult.kt @@ -1,13 +1,16 @@ package com.github.shortiosdk -import com.github.shortiosdk.ShortIOResponseModel - -public sealed class ShortIOResult { +sealed class ShortIOResult { data class Success(val data: ShortIOResponseModel) : ShortIOResult() data class Error(val data: ShortIOErrorModel) : ShortIOResult() } -public sealed class StringOrInt { +sealed class StringOrInt { data class Str(val value: String) : StringOrInt() data class IntVal(val value: Int) : StringOrInt() } + +data class SecureResult( + val securedOriginalURL: String, + val securedShortUrl: String +) \ No newline at end of file diff --git a/ShortIOSDK/src/main/java/com/github/shortiosdk/Model/UrlComponents.kt b/ShortIOSDK/src/main/java/com/github/shortiosdk/Model/UrlComponents.kt index cc5e87a..5a6cba7 100644 --- a/ShortIOSDK/src/main/java/com/github/shortiosdk/Model/UrlComponents.kt +++ b/ShortIOSDK/src/main/java/com/github/shortiosdk/Model/UrlComponents.kt @@ -6,5 +6,6 @@ data class UrlComponents( val path: String?, val query: String?, val fragment: String?, - val fullUrl: String -) \ No newline at end of file + val fullUrl: String, + val destinationUrl: String? = null +) diff --git a/ShortIOSDK/src/main/java/com/github/shortiosdk/ShortIO.kt b/ShortIOSDK/src/main/java/com/github/shortiosdk/ShortIO.kt index 85f7eba..ca07d0d 100644 --- a/ShortIOSDK/src/main/java/com/github/shortiosdk/ShortIO.kt +++ b/ShortIOSDK/src/main/java/com/github/shortiosdk/ShortIO.kt @@ -6,33 +6,140 @@ import okhttp3.RequestBody.Companion.toRequestBody import com.google.gson.GsonBuilder import android.content.Intent import android.net.Uri -import android.util.Log import com.github.shortiosdk.Helpers.StringOrIntSerializer -import com.github.shortiosdk.Helpers.HandleClick +import android.util.Base64 +import android.util.Log +import androidx.annotation.VisibleForTesting +import com.github.shortiosdk.Helpers.extractClidFromUrl +import com.github.shortiosdk.Helpers.removeUtmParams +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.net.HttpURLConnection +import java.net.URL +import java.net.URLEncoder +import java.nio.charset.StandardCharsets +import java.security.SecureRandom +import javax.crypto.Cipher +import javax.crypto.KeyGenerator +import javax.crypto.spec.GCMParameterSpec +import kotlin.collections.component1 +import kotlin.collections.component2 +import kotlin.collections.iterator +import kotlin.collections.joinToString object ShortioSdk { - fun shortenUrl( - apiKey: String, + var apiKey: String = "" + var domain: String = "" + var clid: String = "" + var isInitialized: Boolean = false + + @VisibleForTesting + internal var httpClient: OkHttpClient = OkHttpClient() + + @VisibleForTesting + internal fun resetForTesting(client: OkHttpClient = OkHttpClient()) { + apiKey = "" + domain = "" + clid = "" + isInitialized = false + httpClient = client + } + + /** + * Initialize the SDK through this method to use it. + * Parameters required: + * apiKey of type String + * domain of type String + */ + fun initialize( apiKey: String, domain: String) { + if (!isInitialized){ + this.apiKey = apiKey + this.domain = domain + isInitialized = true + } else{ + Log.d("SDK Initialization","SDK is already Initialized") + } + } + + /** + * Use this method to check the SDK is already initialized + */ + fun isSdkInitialized(): Boolean { + if (!isInitialized) { + throw IllegalStateException("SDK is not initialized. Please initialize the SDK before using it.") + } + return true + } + + /** + * This function will deprecates soon. "Use shortenUrl(parameters) instead" + * Create ShortUrl by using shortenUrl Method. + * Parameters:- It takes ShortIOParameters as parameter which includes originalUrl, domain, clocking, password, title etc. It also apiKey. + * Blocks while the request runs. Call it from a background thread. + */ + @Deprecated( + message = "Use createShortLink(parameters) instead", + replaceWith = ReplaceWith("createShortLink(parameters)"), + level = DeprecationLevel.WARNING + ) + fun createShortLink( + parameters: ShortIOParameters, + apiKey: String? = null + ): ShortIOResult { + return performCreateShortLink( + parameters = parameters, + apiKey = apiKey ?: ShortioSdk.apiKey + ) + } + + /** + * This is new method of creating short URL. + * Create ShortUrl by using shortenUrl(parameters: ShortIOParameters) Method + * Parameters:- It takes ShortIOParameters as parameter which includes originalUrl, domain, clocking, password, title etc. + * Blocks while the request runs. Call it from a background thread. + */ + fun createShortLink( parameters: ShortIOParameters ): ShortIOResult { + return performCreateShortLink( + parameters = parameters, + apiKey = ShortioSdk.apiKey + ) + } + + private fun performCreateShortLink(parameters: ShortIOParameters, apiKey: String): ShortIOResult { + if (apiKey.isBlank()) { + return ShortIOResult.Error( + ShortIOErrorModel( + message = "No API key. Call initialize() or pass one to createShortLink().", + statusCode = null, + code = "NOT_INITIALIZED", + success = false + ) + ) + } + val gson = GsonBuilder() .registerTypeAdapter(StringOrInt::class.java, StringOrIntSerializer()) .create() - val client = OkHttpClient() + if (parameters.domain.isNullOrBlank()) { + parameters.domain = domain + } + val mediaType = "application/json".toMediaType() val jsonBody = gson.toJson(parameters) val body = jsonBody.toRequestBody(mediaType) val request = Request.Builder() - .url(shortenUrl) + .url(baseURL) .post(body) .addHeader("accept", "application/json") .addHeader("content-type", "application/json") .addHeader("authorization", apiKey) .build() - val response = client.newCall(request).execute() + val response = httpClient.newCall(request).execute() val responseBody = response.body?.string() return if (response.isSuccessful) { @@ -69,34 +176,161 @@ object ShortioSdk { return ShortIOResult.Error(errorModel) } } - - fun handleIntent(intent: Intent): UrlComponents? { + + /** + * handleIntent() method is used handle the intent and it returns UrlComponents + * Parameters: intent of type Intent + * Returns: UrlComponents which includes scheme, host, path, destinationUrl, etc. + */ + suspend fun handleIntent(intent: Intent): UrlComponents? { val uri = intent.data ?: return null val scheme = uri.scheme?.lowercase() if (scheme != "http" && scheme != "https") return null val host = uri.host ?: return null - var response: String? = null - val thread = Thread { - response = HandleClick(uri.toString()) - Log.d("Response", "Response: $response") - } - thread.start() - thread.join() + val shortioClidUrl = handleClick(uri.toString()) + clid = shortioClidUrl.let { it?.let { urlString -> extractClidFromUrl(urlString) } ?: "" } + + val destinationUrl = shortioClidUrl?.let { removeUtmParams(it) } - if (response == "200") { - Log.d("Success","Response:-${response}") - } else { - Log.d("Error","Error:- ${response}") - } return UrlComponents( scheme = scheme, host = host, path = uri.path?.removePrefix("/"), query = uri.encodedQuery, fragment = uri.fragment, - fullUrl = uri.toString() + fullUrl = uri.toString(), + destinationUrl = destinationUrl ) } + + /** + * handleClick() is used to track the click + * Parameters: It takes uriString of type String as parameter. + * Returns the redirect target, or null if there is none or the request failed. + */ + suspend fun handleClick(uriString: String): String? = withContext(Dispatchers.IO) { + try { + val uri = Uri.parse(uriString) + val alreadyTagged = uri.getQueryParameter("utm_medium").equals("android", ignoreCase = true) + val urlString = if (alreadyTagged) { + uriString + } else { + uri.buildUpon().appendQueryParameter("utm_medium", "android").build().toString() + } + + val connection = URL(urlString).openConnection() as HttpURLConnection + + connection.requestMethod = "HEAD" + connection.instanceFollowRedirects = false + + connection.connect() + + val redirectedUrl = connection.getHeaderField("Location") + + connection.disconnect() + + redirectedUrl + + } catch (e: Exception) { + Log.e("ShortioSdk", "handleClick failed", e) + null + } + } + + /** + * It creates a Secure URL + * parameters: originalURL: String + * Returns: SecureResult which includes securedOriginalURL and securedShortUrl + */ + fun createSecure(originalURL: String): SecureResult { + return try { + + val keyGenerator = KeyGenerator.getInstance("AES") + keyGenerator.init(128) + val secretKey = keyGenerator.generateKey() + + val iv = ByteArray(12) + SecureRandom().nextBytes(iv) + + val cipher = Cipher.getInstance("AES/GCM/NoPadding") + val spec = GCMParameterSpec(128, iv) + cipher.init(Cipher.ENCRYPT_MODE, secretKey, spec) + val urlBytes = originalURL.toByteArray(StandardCharsets.UTF_8) + val encryptedBytes = cipher.doFinal(urlBytes) + + val encryptedUrlBase64 = Base64.encodeToString(encryptedBytes, Base64.NO_WRAP) + val encryptedIvBase64 = Base64.encodeToString(iv, Base64.NO_WRAP) + val securedOriginalURL = "shortsecure://$encryptedUrlBase64?$encryptedIvBase64" + + val rawKey = secretKey.encoded + val keyBase64 = Base64.encodeToString(rawKey, Base64.NO_WRAP) + val securedShortUrl = "#$keyBase64" + + SecureResult(securedOriginalURL, securedShortUrl) + } catch (e: Exception) { + Log.e("ShortioSdk", "createSecure failed", e) + throw e + } + } + + /** + * trackConversion() method is used to track the conversion. + * parameters: clid: String? = null, domain: String? = null, conversionId: String? = null + * conversionId can be 'signup', 'purchase', 'download', etc. + * Returns: Result Boolean based on status code + */ + suspend fun trackConversion( + clid: String? = null, + domain: String? = null, + conversionId: String? = null + ): Boolean = withContext(Dispatchers.IO) { + try { + + // Build query params + val queryParams = mutableListOf>() + if (!conversionId.isNullOrEmpty()) { + queryParams.add("c" to conversionId) + } + var finalDomain = if (!domain.isNullOrEmpty()) { + domain + } else { + ShortioSdk.domain + } + + var finalClid = if (!clid.isNullOrEmpty()) { + clid + } else { + ShortioSdk.clid + } + + if (!finalClid.isNullOrEmpty()) { + queryParams.add("clid" to finalClid) + } + + val queryString = queryParams.joinToString("&") { + "${it.first}=${URLEncoder.encode(it.second, "UTF-8")}" + } + + if (finalDomain.isNullOrEmpty()) { + Log.e("ShortioSdk", "trackConversion needs a domain; call initialize() first") + return@withContext false + } + + val finalUrl = "https://$finalDomain/.shortio/conversion?$queryString" + + val request = Request.Builder() + .url(finalUrl) + .get() + .build() + + httpClient.newCall(request).execute().use { response -> + return@withContext response.isSuccessful + } + } catch (e: Exception) { + Log.e("ShortioSdk", "trackConversion failed", e) + return@withContext false + } + } } diff --git a/ShortIOSDK/src/test/java/com/github/shortiosdk/BackwardCompatibilityTests.kt b/ShortIOSDK/src/test/java/com/github/shortiosdk/BackwardCompatibilityTests.kt new file mode 100644 index 0000000..6ac1c58 --- /dev/null +++ b/ShortIOSDK/src/test/java/com/github/shortiosdk/BackwardCompatibilityTests.kt @@ -0,0 +1,26 @@ +package com.github.shortiosdk + +import org.junit.Assert.assertEquals +import org.junit.Test + +@Suppress("DEPRECATION") +class BackwardCompatibilityTests { + + @Test + fun theOldShortenUrlNameStillResolves() { + assertEquals(baseURL, shortenUrl) + } + + @Test + fun urlComponentsStillConstructsWithoutADestination() { + val c = UrlComponents( + scheme = "https", + host = "x.gy", + path = "abc", + query = null, + fragment = null, + fullUrl = "https://x.gy/abc" + ) + assertEquals(null, c.destinationUrl) + } +} diff --git a/ShortIOSDK/src/test/java/com/github/shortiosdk/CreateSecureTests.kt b/ShortIOSDK/src/test/java/com/github/shortiosdk/CreateSecureTests.kt new file mode 100644 index 0000000..3b7ba3d --- /dev/null +++ b/ShortIOSDK/src/test/java/com/github/shortiosdk/CreateSecureTests.kt @@ -0,0 +1,41 @@ +package com.github.shortiosdk + +import android.util.Base64 +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import javax.crypto.Cipher +import javax.crypto.spec.GCMParameterSpec +import javax.crypto.spec.SecretKeySpec + +@RunWith(RobolectricTestRunner::class) +class CreateSecureTests { + + @Test + fun securedUrlDecryptsBackAndNeverReusesAnIv() { + val original = "https://example.com/private?token=1" + val result = ShortioSdk.createSecure(original) + + assertEquals(true, result.securedOriginalURL.startsWith("shortsecure://")) + assertEquals(true, result.securedShortUrl.startsWith("#")) + + val payload = result.securedOriginalURL.removePrefix("shortsecure://") + val cipherText = Base64.decode(payload.substringBefore("?"), Base64.NO_WRAP) + val iv = Base64.decode(payload.substringAfter("?"), Base64.NO_WRAP) + val key = Base64.decode(result.securedShortUrl.removePrefix("#"), Base64.NO_WRAP) + + assertEquals(12, iv.size) + assertEquals(16, key.size) + + val cipher = Cipher.getInstance("AES/GCM/NoPadding") + cipher.init(Cipher.DECRYPT_MODE, SecretKeySpec(key, "AES"), GCMParameterSpec(128, iv)) + assertEquals(original, String(cipher.doFinal(cipherText), Charsets.UTF_8)) + + val ivs = (1..5).map { + ShortioSdk.createSecure(original) + .securedOriginalURL.removePrefix("shortsecure://").substringAfter("?") + } + assertEquals(5, ivs.toSet().size) + } +} diff --git a/ShortIOSDK/src/test/java/com/github/shortiosdk/ExampleUnitTest.kt b/ShortIOSDK/src/test/java/com/github/shortiosdk/ExampleUnitTest.kt deleted file mode 100644 index 5ea7e0f..0000000 --- a/ShortIOSDK/src/test/java/com/github/shortiosdk/ExampleUnitTest.kt +++ /dev/null @@ -1,17 +0,0 @@ -package com.github.shortiosdk - -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) - } -} \ No newline at end of file diff --git a/ShortIOSDK/src/test/java/com/github/shortiosdk/HandleClickTests.kt b/ShortIOSDK/src/test/java/com/github/shortiosdk/HandleClickTests.kt new file mode 100644 index 0000000..1683c42 --- /dev/null +++ b/ShortIOSDK/src/test/java/com/github/shortiosdk/HandleClickTests.kt @@ -0,0 +1,109 @@ +package com.github.shortiosdk + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +/** handleClick uses HttpURLConnection, which an OkHttp interceptor cannot observe. */ +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +class HandleClickTests { + + private lateinit var server: MockWebServer + + @Before + fun setUp() { + server = MockWebServer() + server.start() + ShortioSdk.resetForTesting() + } + + @After + fun tearDown() { + server.shutdown() + ShortioSdk.resetForTesting() + } + + private fun redirectTo(location: String) = + MockResponse().setResponseCode(302).setHeader("Location", location) + + @Test + fun utmMediumIsAppendedExactlyOnce() = runTest { + repeat(3) { server.enqueue(redirectTo("https://example.com/dest")) } + + ShortioSdk.handleClick(server.url("/abc").toString()) + assertEquals("/abc?utm_medium=android", server.takeRequest().path) + + ShortioSdk.handleClick(server.url("/abc?clid=123").toString()) + assertEquals("/abc?clid=123&utm_medium=android", server.takeRequest().path) + + ShortioSdk.handleClick(server.url("/abc?utm_medium=android").toString()) + assertEquals("/abc?utm_medium=android", server.takeRequest().path) + } + + @Test + fun utmMediumIsAppendedToTheQueryEvenWhenTheUrlHasAFragment() = runTest { + server.enqueue(redirectTo("https://example.com/dest")) + + ShortioSdk.handleClick(server.url("/abc").toString() + "#section") + + assertEquals("/abc?utm_medium=android", server.takeRequest().path) + } + + @Test + fun aCampaignMediumDoesNotSuppressTheAndroidTag() = runTest { + server.enqueue(redirectTo("https://example.com/dest")) + + ShortioSdk.handleClick(server.url("/abc?utm_source=x&utm_medium=cpc").toString()) + + assertEquals( + "/abc?utm_source=x&utm_medium=cpc&utm_medium=android", + server.takeRequest().path + ) + } + + @Test + fun theLocationHeaderIsReturnedAsTheDestination() = runTest { + server.enqueue(redirectTo("https://example.com/dest")) + + assertEquals( + "https://example.com/dest", + ShortioSdk.handleClick(server.url("/abc").toString()) + ) + assertEquals("HEAD", server.takeRequest().method) + } + + @Test + fun redirectIsReportedRatherThanFollowed() = runTest { + // A cross-scheme destination is refused before the redirect flag applies. + val destination = server.url("/dest").toString() + server.enqueue(redirectTo(destination)) + server.enqueue(MockResponse().setResponseCode(200)) + + assertEquals(destination, ShortioSdk.handleClick(server.url("/abc").toString())) + assertEquals(1, server.requestCount) + } + + @Test + fun aResponseWithoutALocationHeaderYieldsNull() = runTest { + server.enqueue(MockResponse().setResponseCode(200)) + + assertNull(ShortioSdk.handleClick(server.url("/abc").toString())) + } + + @Test + fun anUnreachableServerYieldsNull() = runTest { + val url = server.url("/abc").toString() + server.shutdown() + + assertNull(ShortioSdk.handleClick(url)) + } +} diff --git a/ShortIOSDK/src/test/java/com/github/shortiosdk/HandleIntentTests.kt b/ShortIOSDK/src/test/java/com/github/shortiosdk/HandleIntentTests.kt new file mode 100644 index 0000000..16e4d86 --- /dev/null +++ b/ShortIOSDK/src/test/java/com/github/shortiosdk/HandleIntentTests.kt @@ -0,0 +1,69 @@ +package com.github.shortiosdk + +import android.content.Intent +import android.net.Uri +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +class HandleIntentTests { + + private lateinit var server: MockWebServer + + @Before + fun setUp() { + server = MockWebServer() + server.start() + ShortioSdk.resetForTesting() + } + + @After + fun tearDown() { + server.shutdown() + ShortioSdk.resetForTesting() + } + + private fun viewIntent(url: String) = Intent(Intent.ACTION_VIEW, Uri.parse(url)) + + @Test + fun aNonWebSchemeIsIgnored() = runTest { + assertNull(ShortioSdk.handleIntent(viewIntent("shortsecure://payload"))) + } + + @Test + fun anUnresolvableLinkLeavesTheDestinationNull() = runTest { + server.enqueue(MockResponse().setResponseCode(200)) + + val components = ShortioSdk.handleIntent(viewIntent(server.url("/abc").toString()))!! + + assertNull(components.destinationUrl) + assertEquals("", ShortioSdk.clid) + } + + @Test + fun webLinksYieldComponentsWithUtmStrippedAndTheClidCaptured() = runTest { + server.enqueue( + MockResponse().setResponseCode(302).setHeader( + "Location", "https://example.com/dest?utm_medium=android&clid=c1&id=7" + ) + ) + + val link = server.url("/abc?clid=c1").toString() + val components = ShortioSdk.handleIntent(viewIntent(link))!! + + assertEquals("http", components.scheme) + assertEquals("abc", components.path) + assertEquals("https://example.com/dest?clid=c1&id=7", components.destinationUrl) + assertEquals("c1", ShortioSdk.clid) + } +} diff --git a/ShortIOSDK/src/test/java/com/github/shortiosdk/ShortIOSdkTests.kt b/ShortIOSDK/src/test/java/com/github/shortiosdk/ShortIOSdkTests.kt new file mode 100644 index 0000000..9223214 --- /dev/null +++ b/ShortIOSDK/src/test/java/com/github/shortiosdk/ShortIOSdkTests.kt @@ -0,0 +1,221 @@ +package com.github.shortiosdk + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +class ShortIOSdkTests { + + private lateinit var stub: StubInterceptor + + @Before + fun setUp() { + stub = StubInterceptor() + ShortioSdk.resetForTesting(stub.client()) + } + + @After + fun tearDown() { + ShortioSdk.resetForTesting() + } + + private fun initialized() = ShortioSdk.initialize("sk_test", "example.short.gy") + + @Test + fun configurationIsStoredOnceAndGuardsUninitializedUse() { + assertThrows(IllegalStateException::class.java) { ShortioSdk.isSdkInitialized() } + + initialized() + assertEquals("sk_test", ShortioSdk.apiKey) + assertEquals("example.short.gy", ShortioSdk.domain) + assertTrue(ShortioSdk.isSdkInitialized()) + + ShortioSdk.initialize("sk_second", "second.short.gy") + assertEquals("sk_test", ShortioSdk.apiKey) + assertEquals("example.short.gy", ShortioSdk.domain) + } + + @Test + fun initializeNeverWritesTheApiKeyToStdout() { + val captured = java.io.ByteArrayOutputStream() + val original = System.out + System.setOut(java.io.PrintStream(captured)) + try { + ShortioSdk.initialize("sk_super_secret", "example.short.gy") + } finally { + System.setOut(original) + } + + assertFalse(captured.toString().contains("sk_super_secret")) + } + + @Test + fun createShortLinkPostsAuthenticatedJsonAndMapsSuccess() { + initialized() + stub.respond(200, """{"originalURL":"https://example.com","shortURL":"https://x.gy/abc"}""") + + val result = ShortioSdk.createShortLink( + ShortIOParameters( + originalURL = "https://example.com", + expiresAt = StringOrInt.IntVal(1893456000) + ) + ) + + val request = stub.lastRequest + assertEquals("POST", request.method) + assertEquals("https://api.short.io/links/public", request.url.toString()) + assertEquals("sk_test", request.header("authorization")) + assertTrue(stub.lastBody.contains("\"expiresAt\":1893456000")) + + assertTrue(result is ShortIOResult.Success) + assertEquals("https://x.gy/abc", (result as ShortIOResult.Success).data.shortURL) + } + + @Test + fun createShortLinkForwardsAServerErrorWithItsStatusCode() { + initialized() + stub.respond(400, """{"success":false,"code":"DomainNotFound","message":"Domain not found"}""") + + val result = ShortioSdk.createShortLink(ShortIOParameters("https://example.com")) + + assertTrue(result is ShortIOResult.Error) + assertEquals("DomainNotFound", (result as ShortIOResult.Error).data.code) + assertEquals("Domain not found", result.data.message) + assertEquals(400, result.data.statusCode) + } + + @Test + fun createShortLinkReportsAnEmptySuccessBodyAsMalformed() { + initialized() + stub.respond(200, "") + + val result = ShortioSdk.createShortLink(ShortIOParameters("https://example.com")) + + assertTrue(result is ShortIOResult.Error) + assertEquals("MALFORMED_SUCCESS", (result as ShortIOResult.Error).data.code) + assertEquals(200, result.data.statusCode) + } + + @Test + fun createShortLinkReportsANonJsonErrorBodyAsInvalidJson() { + initialized() + stub.respond(502, "Bad Gateway") + + val result = ShortioSdk.createShortLink(ShortIOParameters("https://example.com")) + + assertTrue(result is ShortIOResult.Error) + assertEquals("INVALID_JSON", (result as ShortIOResult.Error).data.code) + assertEquals(502, result.data.statusCode) + } + + @Test + fun createShortLinkUsesTheConfiguredDomainOnlyWhenNoneIsGiven() { + initialized() + stub.respond(200, """{"originalURL":"https://example.com"}""") + + val fallback = ShortIOParameters(originalURL = "https://example.com") + ShortioSdk.createShortLink(fallback) + assertEquals("example.short.gy", fallback.domain) + + val explicit = ShortIOParameters("https://example.com", domain = "other.short.gy") + ShortioSdk.createShortLink(explicit) + assertEquals("other.short.gy", explicit.domain) + } + + @Test + fun trackConversionReportsFailureRatherThanThrowingWithNoDomain() = runTest { + assertEquals(false, ShortioSdk.trackConversion(conversionId = "purchase")) + assertEquals(0, stub.requests.size) + } + + @Suppress("DEPRECATION") + @Test + fun theDeprecatedOverloadFallsBackToTheConfiguredKeyRatherThanSendingNull() { + initialized() + stub.respond(200, """{"originalURL":"https://example.com"}""") + + ShortioSdk.createShortLink(ShortIOParameters("https://example.com"), null) + + assertEquals("sk_test", stub.lastRequest.header("authorization")) + } + + @Test + fun createShortLinkWithoutAnApiKeyFailsWithoutCallingTheApi() { + val result = ShortioSdk.createShortLink(ShortIOParameters("https://example.com")) + + assertTrue(result is ShortIOResult.Error) + assertEquals("NOT_INITIALIZED", (result as ShortIOResult.Error).data.code) + assertEquals(null, result.data.statusCode) + assertEquals(0, stub.requests.size) + } + + @Test + fun trackConversionReportsFailureRatherThanThrowingOnTransportError() = runTest { + initialized() + stub.failWith(java.io.IOException("no route to host")) + + assertEquals(false, ShortioSdk.trackConversion(conversionId = "purchase")) + } + + @Test + fun outOfRangeParametersAreRejectedAtConstruction() { + assertThrows(IllegalArgumentException::class.java) { + ShortIOParameters("https://example.com", clicksLimit = 0) + } + assertThrows(IllegalArgumentException::class.java) { + ShortIOParameters("https://example.com", splitPercent = 101) + } + assertEquals(1, ShortIOParameters("https://example.com", splitPercent = 1).splitPercent) + } + + @Test + fun trackConversionFallsBackToTheConfiguredDomainAndStoredClid() = runTest { + initialized() + ShortioSdk.clid = "clid_from_open" + stub.respond(200) + + ShortioSdk.trackConversion(conversionId = "purchase") + + assertEquals( + "https://example.short.gy/.shortio/conversion?c=purchase&clid=clid_from_open", + stub.lastRequest.url.toString() + ) + } + + @Test + fun trackConversionPrefersExplicitValuesAndPercentEncodesThem() = runTest { + initialized() + ShortioSdk.clid = "clid_from_open" + stub.respond(200) + + ShortioSdk.trackConversion( + clid = "a b&c", + domain = "other.short.gy", + conversionId = "sign up" + ) + + assertEquals("other.short.gy", stub.lastRequest.url.host) + assertEquals("c=sign+up&clid=a+b%26c", stub.lastRequest.url.encodedQuery) + } + + @Test + fun trackConversionReportsTheTransportStatus() = runTest { + initialized() + + stub.respond(200) + assertTrue(ShortioSdk.trackConversion(conversionId = "purchase")) + + stub.respond(404) + assertEquals(false, ShortioSdk.trackConversion(conversionId = "purchase")) + } +} diff --git a/ShortIOSDK/src/test/java/com/github/shortiosdk/StubInterceptor.kt b/ShortIOSDK/src/test/java/com/github/shortiosdk/StubInterceptor.kt new file mode 100644 index 0000000..6f3ab7a --- /dev/null +++ b/ShortIOSDK/src/test/java/com/github/shortiosdk/StubInterceptor.kt @@ -0,0 +1,56 @@ +package com.github.shortiosdk + +import okhttp3.Interceptor +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Protocol +import okhttp3.Request +import okhttp3.Response +import okhttp3.ResponseBody.Companion.toResponseBody +import okio.Buffer +import java.io.IOException + +/** Answers every call from memory so the suite never touches the network. */ +class StubInterceptor : Interceptor { + + private val recorded = mutableListOf() + private var responder: (Request) -> Response = { + throw IllegalStateException("No stub configured for ${it.url}") + } + + val requests: List get() = recorded + val lastRequest: Request get() = recorded.last() + + val lastBody: String + get() = lastRequest.body?.let { Buffer().also(it::writeTo).readUtf8() } ?: "" + + fun respond(code: Int, body: String = "", headers: Map = emptyMap()) { + responder = { request -> build(request, code, body, headers) } + } + + fun failWith(cause: IOException) { + responder = { throw cause } + } + + fun client(): OkHttpClient = OkHttpClient.Builder().addInterceptor(this).build() + + override fun intercept(chain: Interceptor.Chain): Response { + val request = chain.request() + recorded += request + return responder(request) + } + + private fun build( + request: Request, + code: Int, + body: String, + headers: Map + ): Response = Response.Builder() + .request(request) + .protocol(Protocol.HTTP_1_1) + .code(code) + .message(if (code in 200..299) "OK" else "Error") + .apply { headers.forEach { (name, value) -> header(name, value) } } + .body(body.toResponseBody("application/json".toMediaType())) + .build() +} diff --git a/ShortIOSDK/src/test/resources/robolectric.properties b/ShortIOSDK/src/test/resources/robolectric.properties new file mode 100644 index 0000000..979b5ee --- /dev/null +++ b/ShortIOSDK/src/test/resources/robolectric.properties @@ -0,0 +1 @@ +sdk=34 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 73f5485..58277f6 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -7,6 +7,10 @@ junitVersion = "1.2.1" espressoCore = "3.6.1" appcompat = "1.7.0" material = "1.12.0" +# test-only +coroutines = "1.6.4" +robolectric = "4.14.1" +okhttp = "4.12.0" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } @@ -15,6 +19,9 @@ androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "j androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" } androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" } material = { group = "com.google.android.material", name = "material", version.ref = "material" } +robolectric = { group = "org.robolectric", name = "robolectric", version.ref = "robolectric" } +mockwebserver = { group = "com.squareup.okhttp3", name = "mockwebserver", version.ref = "okhttp" } +kotlinx-coroutines-test = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-test", version.ref = "coroutines" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" } diff --git a/jitpack.yml b/jitpack.yml new file mode 100644 index 0000000..8bb9323 --- /dev/null +++ b/jitpack.yml @@ -0,0 +1,3 @@ +# Without this JitPack starts on 11 and downloads 17 mid-build. +jdk: + - openjdk17