From d7b1da05b6b6a5a28644833f4fa5c92a912d16cc Mon Sep 17 00:00:00 2001 From: rajpootathar Date: Wed, 18 Jun 2025 11:20:39 +0500 Subject: [PATCH 01/18] optimized the code --- README.md | 6 +++--- .../src/main/java/com/github/shortiosdk/Constants.kt | 2 +- .../com/github/shortiosdk/Model/ShortIOParameters.kt | 1 - .../java/com/github/shortiosdk/Model/ShortIOResult.kt | 6 ++---- .../src/main/java/com/github/shortiosdk/ShortIO.kt | 9 ++++----- 5 files changed, 10 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index e43a543..6b6687f 100644 --- a/README.md +++ b/README.md @@ -52,7 +52,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.5") // Use this } ``` ### 3. Sync the Project @@ -104,11 +104,11 @@ thread { try { when (val result = ShortioSdk.shortenUrl(apiKey, 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) { diff --git a/ShortIOSDK/src/main/java/com/github/shortiosdk/Constants.kt b/ShortIOSDK/src/main/java/com/github/shortiosdk/Constants.kt index 1853fea..88003dc 100644 --- a/ShortIOSDK/src/main/java/com/github/shortiosdk/Constants.kt +++ b/ShortIOSDK/src/main/java/com/github/shortiosdk/Constants.kt @@ -1,3 +1,3 @@ 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" \ No newline at end of file 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..b9cb0f7 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, 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..4628080 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,11 @@ 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() } diff --git a/ShortIOSDK/src/main/java/com/github/shortiosdk/ShortIO.kt b/ShortIOSDK/src/main/java/com/github/shortiosdk/ShortIO.kt index 85f7eba..e943649 100644 --- a/ShortIOSDK/src/main/java/com/github/shortiosdk/ShortIO.kt +++ b/ShortIOSDK/src/main/java/com/github/shortiosdk/ShortIO.kt @@ -5,7 +5,6 @@ import okhttp3.MediaType.Companion.toMediaType 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 @@ -25,7 +24,7 @@ object ShortioSdk { val body = jsonBody.toRequestBody(mediaType) val request = Request.Builder() - .url(shortenUrl) + .url(baseURL) .post(body) .addHeader("accept", "application/json") .addHeader("content-type", "application/json") @@ -80,15 +79,15 @@ object ShortioSdk { var response: String? = null val thread = Thread { response = HandleClick(uri.toString()) - Log.d("Response", "Response: $response") + Log.d("HandleClickResponse", "Response: $response") } thread.start() thread.join() if (response == "200") { - Log.d("Success","Response:-${response}") + Log.d("HandleClickResponse","Short SDK click call completed successfully.") } else { - Log.d("Error","Error:- ${response}") + Log.d("HandleClickError","Error:- ${response}") } return UrlComponents( scheme = scheme, From ae86b12b566a7e57ecbe630df4e154e37ea29dd5 Mon Sep 17 00:00:00 2001 From: rajpootathar Date: Fri, 27 Jun 2025 18:24:50 +0500 Subject: [PATCH 02/18] integrated encrypted links functionality and updated README.md file --- README.md | 27 +++++++--- .../github/shortiosdk/Model/ShortIOResult.kt | 5 ++ .../java/com/github/shortiosdk/ShortIO.kt | 49 ++++++++++++++++--- 3 files changed, 68 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 6b6687f..9c6d986 100644 --- a/README.md +++ b/README.md @@ -52,7 +52,7 @@ It will be: ```kotlin dependencies { implementation("com.github.User:Repo:Tag") // Example - implementation("com.github.Short-io:android-sdk:v1.0.5") // Use this + implementation("com.github.Short-io:android-sdk:v1.0.6") // Use this } ``` ### 3. Sync the Project @@ -241,7 +241,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 @@ -260,8 +260,10 @@ 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) + Log.d("New Intent", "Host: ${result?.host}, Path: ${result?.path}") + } } ``` @@ -270,13 +272,26 @@ override fun onNewIntent(intent: Intent) { ```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}") + Log.d("New Intent", "Host: ${result?.host}, Path: ${result?.path}") + } } ``` +### πŸ” 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}") +``` -### βœ… Final Checklist +### βœ… Final Checklist for Deep Linking * App is signed with the correct keystore. 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 4628080..51aff88 100644 --- a/ShortIOSDK/src/main/java/com/github/shortiosdk/Model/ShortIOResult.kt +++ b/ShortIOSDK/src/main/java/com/github/shortiosdk/Model/ShortIOResult.kt @@ -9,3 +9,8 @@ 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/ShortIO.kt b/ShortIOSDK/src/main/java/com/github/shortiosdk/ShortIO.kt index e943649..43ff43b 100644 --- a/ShortIOSDK/src/main/java/com/github/shortiosdk/ShortIO.kt +++ b/ShortIOSDK/src/main/java/com/github/shortiosdk/ShortIO.kt @@ -8,6 +8,14 @@ import android.content.Intent import android.util.Log import com.github.shortiosdk.Helpers.StringOrIntSerializer import com.github.shortiosdk.Helpers.HandleClick +import android.util.Base64 +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.nio.charset.StandardCharsets +import java.security.SecureRandom +import javax.crypto.Cipher +import javax.crypto.KeyGenerator +import javax.crypto.spec.GCMParameterSpec object ShortioSdk { @@ -69,20 +77,16 @@ object ShortioSdk { } } - fun handleIntent(intent: Intent): UrlComponents? { + 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("HandleClickResponse", "Response: $response") + val response = withContext(Dispatchers.IO) { + HandleClick(uri.toString()) } - thread.start() - thread.join() if (response == "200") { Log.d("HandleClickResponse","Short SDK click call completed successfully.") @@ -98,4 +102,35 @@ object ShortioSdk { fullUrl = uri.toString() ) } + + 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) { + e.printStackTrace() + throw e + } + } } From b13edc915bf4ec8410712c61425d270144883747 Mon Sep 17 00:00:00 2001 From: rajpootathar Date: Fri, 8 Aug 2025 20:02:42 +0500 Subject: [PATCH 03/18] add link conversion tracking and return destination URL from SDK --- .../shortiosdk/Helpers/HelperMethods.kt | 83 +++++++++++++++++++ .../github/shortiosdk/Model/UrlComponents.kt | 3 +- .../java/com/github/shortiosdk/ShortIO.kt | 15 +++- 3 files changed, 99 insertions(+), 2 deletions(-) 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..b3fa367 100644 --- a/ShortIOSDK/src/main/java/com/github/shortiosdk/Helpers/HelperMethods.kt +++ b/ShortIOSDK/src/main/java/com/github/shortiosdk/Helpers/HelperMethods.kt @@ -1,7 +1,17 @@ package com.github.shortiosdk.Helpers +import android.net.Uri +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import okhttp3.OkHttpClient import okhttp3.Request +import okhttp3.Response +import java.net.HttpURLConnection +import java.net.URL +import kotlin.collections.component1 +import kotlin.collections.component2 +import kotlin.collections.iterator +import kotlin.collections.joinToString fun HandleClick(uri: String): String? { @@ -29,4 +39,77 @@ fun HandleClick(uri: String): String? { } catch (e: Exception) { e.toString() } +} + +fun extractClidFromUrl(urlString: String): String? { + return try { + val uri = Uri.parse(urlString) + uri.getQueryParameter("clid") + } catch (e: Exception) { + e.printStackTrace() + null + } +} + +suspend fun fetchHeadersValue(urlString: String): String? { + return try { + val url = URL(urlString) + val connection = withContext(Dispatchers.IO) { + url.openConnection() as HttpURLConnection + } + + connection.requestMethod = "HEAD" + connection.instanceFollowRedirects = false + + connection.connect() + + println("Response Headers:") + for ((key, value) in connection.headerFields) { + if (key != null && value != null) { + println(" $key: ${value.joinToString()}") + } + } + + val redirectedUrl = connection.getHeaderField("Location") + + connection.disconnect() + + redirectedUrl ?: "Not Found" + } catch (e: Exception) { + println("Network error: ${e.localizedMessage}") + null + } +} + +suspend fun trackConversion( + originalURL: String, + clid: String? +): Boolean = withContext(Dispatchers.IO) { + val client = OkHttpClient() + + val baseURL = if (originalURL.endsWith("/")) originalURL.dropLast(1) else originalURL + + val conversionURLString = "$baseURL/.shortio/conversion?clid=$clid" + + try { + val request = Request.Builder() + .url(conversionURLString) + .get() + .build() + + val response: Response = client.newCall(request).execute() + response.use { res -> + if (res.isSuccessful) { + val bodyString = res.body?.string() + println("Success! Response body: $bodyString") + } else { + println("Failed with status code: ${res.code}") + println("Response message: ${res.message}") + } + } + response.use { it.isSuccessful } + } catch (e: Exception) { + e.printStackTrace() + false + } } \ 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..791fae9 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 + val fullUrl: String, + val destinationUrl: String? ) \ No newline at end of file diff --git a/ShortIOSDK/src/main/java/com/github/shortiosdk/ShortIO.kt b/ShortIOSDK/src/main/java/com/github/shortiosdk/ShortIO.kt index 43ff43b..4abbba5 100644 --- a/ShortIOSDK/src/main/java/com/github/shortiosdk/ShortIO.kt +++ b/ShortIOSDK/src/main/java/com/github/shortiosdk/ShortIO.kt @@ -9,6 +9,9 @@ import android.util.Log import com.github.shortiosdk.Helpers.StringOrIntSerializer import com.github.shortiosdk.Helpers.HandleClick import android.util.Base64 +import com.github.shortiosdk.Helpers.extractClidFromUrl +import com.github.shortiosdk.Helpers.fetchHeadersValue +import com.github.shortiosdk.Helpers.trackConversion import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import java.nio.charset.StandardCharsets @@ -84,6 +87,15 @@ object ShortioSdk { val host = uri.host ?: return null + val shortioClidUrl = withContext(Dispatchers.IO) { + fetchHeadersValue(uri.toString()) + } + + val clid = shortioClidUrl?.let { extractClidFromUrl(it) } + + val trackingSuccess = trackConversion("https://$host", clid = clid) + println("Conversion success: $trackingSuccess") + val response = withContext(Dispatchers.IO) { HandleClick(uri.toString()) } @@ -99,7 +111,8 @@ object ShortioSdk { path = uri.path?.removePrefix("/"), query = uri.encodedQuery, fragment = uri.fragment, - fullUrl = uri.toString() + fullUrl = uri.toString(), + destinationUrl = shortioClidUrl ) } From 311fbf842ae3dd9b358ec53c4a4bd1717f8b555f Mon Sep 17 00:00:00 2001 From: rajpootathar Date: Mon, 11 Aug 2025 14:49:50 +0500 Subject: [PATCH 04/18] updated readme included section for conversion tracking with better copy --- README.md | 25 ++++++++++++++++++++++--- 1 file changed, 22 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 9c6d986..c27875b 100644 --- a/README.md +++ b/README.md @@ -52,7 +52,7 @@ It will be: ```kotlin dependencies { implementation("com.github.User:Repo:Tag") // Example - implementation("com.github.Short-io:android-sdk:v1.0.6") // Use this + implementation("com.github.Short-io:android-sdk:v1.0.7") // Use this } ``` ### 3. Sync the Project @@ -262,7 +262,7 @@ override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) lifecycleScope.launch { val result = ShortioSdk.handleIntent(intent) - Log.d("New Intent", "Host: ${result?.host}, Path: ${result?.path}") + Log.d("New Intent", "Host: ${result?.host}, Path: ${result?.path}, DestinationURL: ${result?.destinationUrl}") } } ``` @@ -274,7 +274,7 @@ 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}") + Log.d("New Intent", "Host: ${result?.host}, Path: ${result?.path}, DestinationURL: ${result?.destinationUrl}") } } ``` @@ -291,6 +291,25 @@ Log.d("securedOriginalURL", "URL: ${result.securedOriginalURL}") Log.d("securedShortUrl", "URL: ${result.securedShortUrl}") ``` +### πŸ”„ Conversion Tracking + +Conversion tracking is automated in the SDK β€” no manual setup or method calls are required. +When your app is opened via a deep link, the SDK automatically records the conversion in the background through the `handleIntent` intent-handling method. Conversion tracking will only be triggered once `handleIntent` is called, so until that happens, no conversion will be recorded. + +This means: +- No need to call trackConversion manually. +- Conversions are automatically sent whenever the app is launched from a deep link. +- Conversion tracking without additional code. + +Example: +Simply handle incoming deep links with the SDK’s handleOpen method β€” conversion tracking happens automatically: +```kotlin + lifecycleScope.launch { + val result = ShortioSdk.handleIntent(intent) + Log.d("New Intent", "Host: ${result?.host}, Path: ${result?.path}, DestinationURL: ${result?.destinationUrl}") + } +``` + ### βœ… Final Checklist for Deep Linking * App is signed with the correct keystore. From 233a0fb9072e493ec205bc2bbbf12ab373de1625 Mon Sep 17 00:00:00 2001 From: rajpootathar Date: Tue, 12 Aug 2025 20:19:26 +0500 Subject: [PATCH 05/18] * Fixed multiple calls and instances issue --- README.md | 51 +++++---- .../shortiosdk/Helpers/HelperMethods.kt | 101 +++--------------- .../shortiosdk/Model/ShortIOParameters.kt | 2 +- .../java/com/github/shortiosdk/ShortIO.kt | 96 +++++++++++++---- 4 files changed, 128 insertions(+), 122 deletions(-) diff --git a/README.md b/README.md index c27875b..a059bef 100644 --- a/README.md +++ b/README.md @@ -52,7 +52,7 @@ It will be: ```kotlin dependencies { implementation("com.github.User:Repo:Tag") // Example - implementation("com.github.Short-io:android-sdk:v1.0.7") // Use this + implementation("com.github.Short-io:android-sdk:v1.0.8") // Use this } ``` ### 3. Sync the Project @@ -80,6 +80,23 @@ import com.github.shortiosdk.ShortioSdk ### πŸ”— SDK Usage +#### Initialization + +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 authenticating requests. +* domain: The default domain to use for URL shortening. + +##### Create Short URL + ```kotlin import com.github.shortiosdk.ShortioSdk import com.github.shortiosdk.ShortIOParameters @@ -87,7 +104,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) { @@ -98,11 +114,10 @@ try { **Note**: Both `domain` and `originalURL` are the required parameters. 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.shortenUrl(params)) { is ShortIOResult.Success -> { Log.d("ShortIOResult","Shortened URL: ${result.data.shortURL}") } @@ -123,7 +138,7 @@ 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`) | +| `domain` | `String` | ❌ | Your Short.io domain (e.g., `example.short.gy`) | | `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 | @@ -293,22 +308,22 @@ Log.d("securedShortUrl", "URL: ${result.securedShortUrl}") ### πŸ”„ Conversion Tracking -Conversion tracking is automated in the SDK β€” no manual setup or method calls are required. -When your app is opened via a deep link, the SDK automatically records the conversion in the background through the `handleIntent` intent-handling method. Conversion tracking will only be triggered once `handleIntent` is called, so until that happens, no conversion will be recorded. +Track conversions for your short links to measure campaign effectiveness. The SDK provides a simple method to record conversions. -This means: -- No need to call trackConversion manually. -- Conversions are automatically sent whenever the app is launched from a deep link. -- Conversion tracking without additional code. - -Example: -Simply handle incoming deep links with the SDK’s handleOpen method β€” conversion tracking happens automatically: ```kotlin - lifecycleScope.launch { - val result = ShortioSdk.handleIntent(intent) - Log.d("New Intent", "Host: ${result?.host}, Path: ${result?.path}, DestinationURL: ${result?.destinationUrl}") + CoroutineScope(Dispatchers.IO).launch { + try { + val res = ShortioSdk.trackConversion( + "https://{your_domain}/", + conversionId = null + ) + // conversionId can be 'signup', 'purchase', 'download', etc. + Log.d("Handle Conversion Tracking", "Handle Conversion Tracking: $res") + } catch (e: Exception) { + Log.e("Handle Conversion Tracking", "Error calling trackConversion", e) + } } -``` + ### βœ… Final Checklist for Deep Linking 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 b3fa367..91b85b8 100644 --- a/ShortIOSDK/src/main/java/com/github/shortiosdk/Helpers/HelperMethods.kt +++ b/ShortIOSDK/src/main/java/com/github/shortiosdk/Helpers/HelperMethods.kt @@ -5,42 +5,9 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import okhttp3.OkHttpClient import okhttp3.Request -import okhttp3.Response -import java.net.HttpURLConnection import java.net.URL -import kotlin.collections.component1 -import kotlin.collections.component2 -import kotlin.collections.iterator -import kotlin.collections.joinToString -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" - } - - val request = Request.Builder() - .url(url) - .addHeader("accept", "application/json") - .build() - - return try { - client.newCall(request).execute().use { response -> - if (response.isSuccessful) { - response.code.toString() - } else { - "Link is not Valid" - } - } - } catch (e: Exception) { - e.toString() - } -} - fun extractClidFromUrl(urlString: String): String? { return try { val uri = Uri.parse(urlString) @@ -51,65 +18,29 @@ fun extractClidFromUrl(urlString: String): String? { } } -suspend fun fetchHeadersValue(urlString: String): String? { - return try { - val url = URL(urlString) - val connection = withContext(Dispatchers.IO) { - url.openConnection() as HttpURLConnection +suspend fun HandleClick(uriString: String): String? = withContext(Dispatchers.IO) { + try { + val urlString = when { + uriString.contains("utm_medium=android", ignoreCase = true) -> uriString + uriString.contains("?") -> "$uriString&utm_medium=android" + else -> "$uriString?utm_medium=android" } - connection.requestMethod = "HEAD" - connection.instanceFollowRedirects = false - - connection.connect() - - println("Response Headers:") - for ((key, value) in connection.headerFields) { - if (key != null && value != null) { - println(" $key: ${value.joinToString()}") - } - } + val client = OkHttpClient.Builder() + .followRedirects(false) + .build() - val redirectedUrl = connection.getHeaderField("Location") + val request = Request.Builder() + .url(URL(urlString)) + .head() + .build() - connection.disconnect() + client.newCall(request).execute().use { response -> - redirectedUrl ?: "Not Found" + response.header("Location") + } } catch (e: Exception) { println("Network error: ${e.localizedMessage}") null } } - -suspend fun trackConversion( - originalURL: String, - clid: String? -): Boolean = withContext(Dispatchers.IO) { - val client = OkHttpClient() - - val baseURL = if (originalURL.endsWith("/")) originalURL.dropLast(1) else originalURL - - val conversionURLString = "$baseURL/.shortio/conversion?clid=$clid" - - try { - val request = Request.Builder() - .url(conversionURLString) - .get() - .build() - - val response: Response = client.newCall(request).execute() - response.use { res -> - if (res.isSuccessful) { - val bodyString = res.body?.string() - println("Success! Response body: $bodyString") - } else { - println("Failed with status code: ${res.code}") - println("Response message: ${res.message}") - } - } - response.use { it.isSuccessful } - } catch (e: Exception) { - e.printStackTrace() - false - } -} \ No newline at end of file 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 b9cb0f7..e34b3dd 100644 --- a/ShortIOSDK/src/main/java/com/github/shortiosdk/Model/ShortIOParameters.kt +++ b/ShortIOSDK/src/main/java/com/github/shortiosdk/Model/ShortIOParameters.kt @@ -29,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/ShortIO.kt b/ShortIOSDK/src/main/java/com/github/shortiosdk/ShortIO.kt index 4abbba5..ca19c5d 100644 --- a/ShortIOSDK/src/main/java/com/github/shortiosdk/ShortIO.kt +++ b/ShortIOSDK/src/main/java/com/github/shortiosdk/ShortIO.kt @@ -5,15 +5,15 @@ import okhttp3.MediaType.Companion.toMediaType import okhttp3.RequestBody.Companion.toRequestBody import com.google.gson.GsonBuilder import android.content.Intent -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 com.github.shortiosdk.Helpers.extractClidFromUrl -import com.github.shortiosdk.Helpers.fetchHeadersValue -import com.github.shortiosdk.Helpers.trackConversion import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext +import java.net.URI +import java.net.URLEncoder import java.nio.charset.StandardCharsets import java.security.SecureRandom import javax.crypto.Cipher @@ -22,13 +22,32 @@ import javax.crypto.spec.GCMParameterSpec object ShortioSdk { + var savedDomain: String? = null + var storedClid: String = "" + + var apiKey: String = "" + var isInitialized: Boolean = false + + fun initialize( apiKey: String, domain: String) { + if (!isInitialized){ + this.apiKey = apiKey + this.savedDomain = domain + println("SDK initialized with API key: $apiKey") + isInitialized = true + } else{ + Log.d("SDK Initialization","SDK is already Initialzed") + } + } + fun shortenUrl( - apiKey: String, parameters: ShortIOParameters ): ShortIOResult { val gson = GsonBuilder() .registerTypeAdapter(StringOrInt::class.java, StringOrIntSerializer()) .create() + if (parameters.domain.isNullOrBlank()) { + parameters.domain = savedDomain!! + } val client = OkHttpClient() val mediaType = "application/json".toMediaType() val jsonBody = gson.toJson(parameters) @@ -88,23 +107,11 @@ object ShortioSdk { val host = uri.host ?: return null val shortioClidUrl = withContext(Dispatchers.IO) { - fetchHeadersValue(uri.toString()) - } - - val clid = shortioClidUrl?.let { extractClidFromUrl(it) } - - val trackingSuccess = trackConversion("https://$host", clid = clid) - println("Conversion success: $trackingSuccess") - - val response = withContext(Dispatchers.IO) { HandleClick(uri.toString()) } - if (response == "200") { - Log.d("HandleClickResponse","Short SDK click call completed successfully.") - } else { - Log.d("HandleClickError","Error:- ${response}") - } + storedClid = shortioClidUrl.let { it?.let { urlString -> extractClidFromUrl(urlString) } ?: "" } + return UrlComponents( scheme = scheme, host = host, @@ -116,6 +123,11 @@ object ShortioSdk { ) } + /** + * It creates a Secure URL + - parameters: originalURL: String + - Returns: SecureResult + */ fun createSecure(originalURL: String): SecureResult { return try { @@ -146,4 +158,52 @@ object ShortioSdk { throw e } } + + /** + - parameters: originalURL: String, clid: String, conversionId: String? = nil + - conversionId can be 'signup', 'purchase', 'download', etc. + - Returns: Result Boolean based on status code + */ + suspend fun trackConversion( + originalURL: String, + clid: String? = null, + conversionId: String? = null + ): Boolean = withContext(Dispatchers.IO) { + try { + val originalUri = URI(originalURL) + val scheme = originalUri.scheme ?: return@withContext false + val host = originalUri.host ?: return@withContext false + + // Build query params + val queryParams = mutableListOf>() + if (!conversionId.isNullOrEmpty()) { + queryParams.add("c" to conversionId) + } + if (!clid.isNullOrEmpty()) { + queryParams.add("clid" to clid) + } else{ + queryParams.add("clid" to storedClid) + } + + val queryString = queryParams.joinToString("&") { + "${it.first}=${URLEncoder.encode(it.second, "UTF-8")}" + } + + val finalUrl = "$scheme://$host/.shortio/conversion?$queryString" + + val client = OkHttpClient() + + val request = Request.Builder() + .url(finalUrl) + .get() + .build() + + client.newCall(request).execute().use { response -> + return@withContext response.isSuccessful + } + } catch (e: Exception) { + e.printStackTrace() + throw e + } + } } From dfcbc9f0c724459d5460b12302888ba6cd720ca3 Mon Sep 17 00:00:00 2001 From: rajpootathar Date: Wed, 13 Aug 2025 12:12:09 +0500 Subject: [PATCH 06/18] * added comments in the sdk --- README.md | 38 ++++---- .../shortiosdk/Helpers/HelperMethods.kt | 32 ------- .../java/com/github/shortiosdk/ShortIO.kt | 93 ++++++++++++++++--- 3 files changed, 100 insertions(+), 63 deletions(-) diff --git a/README.md b/README.md index a059bef..0959543 100644 --- a/README.md +++ b/README.md @@ -87,15 +87,15 @@ To start using ShortioSdk, you need to initialize it early in your app lifecycle Example: Initialize in Activity ```kotlin - override fun onCreate() { - super.onCreate() - ShortioSdk.initialize(apiKey, domain) - } +override fun onCreate() { + super.onCreate() + ShortioSdk.initialize(apiKey, domain) +} ``` -* apiKey: Your API key string for authenticating requests. +* apiKey: Your API key string for SDK initialization. * domain: The default domain to use for URL shortening. -##### Create Short URL +#### Create Short URL ```kotlin import com.github.shortiosdk.ShortioSdk @@ -111,7 +111,7 @@ 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 @@ -311,19 +311,19 @@ Log.d("securedShortUrl", "URL: ${result.securedShortUrl}") Track conversions for your short links to measure campaign effectiveness. The SDK provides a simple method to record conversions. ```kotlin - CoroutineScope(Dispatchers.IO).launch { - try { - val res = ShortioSdk.trackConversion( - "https://{your_domain}/", - conversionId = null - ) - // conversionId can be 'signup', 'purchase', 'download', etc. - Log.d("Handle Conversion Tracking", "Handle Conversion Tracking: $res") - } catch (e: Exception) { - Log.e("Handle Conversion Tracking", "Error calling trackConversion", e) - } +CoroutineScope(Dispatchers.IO).launch { + try { + val res = ShortioSdk.trackConversion( + "https://{your_domain}/", + conversionId = null + ) + // conversionId can be 'signup', 'purchase', 'download', etc. + Log.d("Handle Conversion Tracking", "Handle Conversion Tracking: $res") + } catch (e: Exception) { + Log.e("Handle Conversion Tracking", "Error calling trackConversion", e) } - +} +``` ### βœ… Final Checklist for Deep Linking 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 91b85b8..dbbd7eb 100644 --- a/ShortIOSDK/src/main/java/com/github/shortiosdk/Helpers/HelperMethods.kt +++ b/ShortIOSDK/src/main/java/com/github/shortiosdk/Helpers/HelperMethods.kt @@ -1,11 +1,6 @@ package com.github.shortiosdk.Helpers import android.net.Uri -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.withContext -import okhttp3.OkHttpClient -import okhttp3.Request -import java.net.URL fun extractClidFromUrl(urlString: String): String? { @@ -17,30 +12,3 @@ fun extractClidFromUrl(urlString: String): String? { null } } - -suspend fun HandleClick(uriString: String): String? = withContext(Dispatchers.IO) { - try { - val urlString = when { - uriString.contains("utm_medium=android", ignoreCase = true) -> uriString - uriString.contains("?") -> "$uriString&utm_medium=android" - else -> "$uriString?utm_medium=android" - } - - val client = OkHttpClient.Builder() - .followRedirects(false) - .build() - - val request = Request.Builder() - .url(URL(urlString)) - .head() - .build() - - client.newCall(request).execute().use { response -> - - response.header("Location") - } - } catch (e: Exception) { - println("Network error: ${e.localizedMessage}") - null - } -} diff --git a/ShortIOSDK/src/main/java/com/github/shortiosdk/ShortIO.kt b/ShortIOSDK/src/main/java/com/github/shortiosdk/ShortIO.kt index ca19c5d..4f7bf68 100644 --- a/ShortIOSDK/src/main/java/com/github/shortiosdk/ShortIO.kt +++ b/ShortIOSDK/src/main/java/com/github/shortiosdk/ShortIO.kt @@ -6,13 +6,13 @@ import okhttp3.RequestBody.Companion.toRequestBody import com.google.gson.GsonBuilder import android.content.Intent import com.github.shortiosdk.Helpers.StringOrIntSerializer -import com.github.shortiosdk.Helpers.HandleClick import android.util.Base64 import android.util.Log import com.github.shortiosdk.Helpers.extractClidFromUrl import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import java.net.URI +import java.net.URL import java.net.URLEncoder import java.nio.charset.StandardCharsets import java.security.SecureRandom @@ -22,12 +22,18 @@ import javax.crypto.spec.GCMParameterSpec object ShortioSdk { + var apiKey: String = "" + var savedDestinationUrl: String = "" var savedDomain: String? = null var storedClid: String = "" - - var apiKey: String = "" var isInitialized: Boolean = false + /** + * 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 @@ -39,9 +45,24 @@ object ShortioSdk { } } + /** + * Use this method to check the SDK is already initialized + */ + fun isSdkInitialized(): Boolean { + if (!isInitialized) { + throw IllegalStateException("SDK is not initialized. Please call initialize before using the SDK.") + } + return true + } + + /** + * Create ShortUrl by using shortenUrl Method + * Parameters:- It takes ShortIOParameters as parameter which includes originalUrl, domain, clocking, password, title etc. + */ fun shortenUrl( parameters: ShortIOParameters ): ShortIOResult { + isSdkInitialized() val gson = GsonBuilder() .registerTypeAdapter(StringOrInt::class.java, StringOrIntSerializer()) .create() @@ -98,7 +119,12 @@ object ShortioSdk { return ShortIOResult.Error(errorModel) } } - + + /** + * handleIntent() method is used handle the intent and it returns UrlComponents + * Parameters: intent of type Intent + * Returns: UrlComponents which includes scheme, host, path, destibnationUrl, etc. + */ suspend fun handleIntent(intent: Intent): UrlComponents? { val uri = intent.data ?: return null val scheme = uri.scheme?.lowercase() @@ -109,7 +135,9 @@ object ShortioSdk { val shortioClidUrl = withContext(Dispatchers.IO) { HandleClick(uri.toString()) } - + if (shortioClidUrl != null) { + savedDestinationUrl = shortioClidUrl + } storedClid = shortioClidUrl.let { it?.let { urlString -> extractClidFromUrl(urlString) } ?: "" } return UrlComponents( @@ -123,12 +151,46 @@ object ShortioSdk { ) } + /** + * HandleClick() is used to track the click + * Parameters: It takes uriString of type String as parameter. + * Returns String + */ + suspend private fun HandleClick(uriString: String): String? = withContext(Dispatchers.IO) { + isSdkInitialized() + try { + val urlString = when { + uriString.contains("utm_medium=android", ignoreCase = true) -> uriString + uriString.contains("?") -> "$uriString&utm_medium=android" + else -> "$uriString?utm_medium=android" + } + + val client = OkHttpClient.Builder() + .followRedirects(false) + .build() + + val request = Request.Builder() + .url(URL(urlString)) + .head() + .build() + + client.newCall(request).execute().use { response -> + + response.header("Location") + } + } catch (e: Exception) { + println("Network error: ${e.localizedMessage}") + null + } + } + /** * It creates a Secure URL - - parameters: originalURL: String - - Returns: SecureResult + * parameters: originalURL: String + * Returns: SecureResult which includes securedOriginalURL and securedShortUrl */ fun createSecure(originalURL: String): SecureResult { + isSdkInitialized() return try { val keyGenerator = KeyGenerator.getInstance("AES") @@ -160,17 +222,24 @@ object ShortioSdk { } /** - - parameters: originalURL: String, clid: String, conversionId: String? = nil - - conversionId can be 'signup', 'purchase', 'download', etc. - - Returns: Result Boolean based on status code + * trackConversion() method is used to track the conversion. + * parameters: originalURL: String, clid: String, conversionId: String? = nil + * conversionId can be 'signup', 'purchase', 'download', etc. + * Returns: Result Boolean based on status code */ suspend fun trackConversion( - originalURL: String, + originalURL: String? = null, clid: String? = null, conversionId: String? = null ): Boolean = withContext(Dispatchers.IO) { + isSdkInitialized() try { - val originalUri = URI(originalURL) + val originalURLString = if (originalURL.isNullOrEmpty()){ + savedDestinationUrl + } else { + originalURL + } + val originalUri = URI(originalURLString) val scheme = originalUri.scheme ?: return@withContext false val host = originalUri.host ?: return@withContext false From d4d974a63652014b45a815a4310958bcd400bf8b Mon Sep 17 00:00:00 2001 From: rajpootathar Date: Wed, 13 Aug 2025 17:22:35 +0500 Subject: [PATCH 07/18] * updated code --- README.md | 14 +- .../shortiosdk/Helpers/HelperMethods.kt | 15 ++ .../java/com/github/shortiosdk/ShortIO.kt | 149 +++++++++++++++--- 3 files changed, 150 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index 0959543..d1405df 100644 --- a/README.md +++ b/README.md @@ -95,7 +95,11 @@ override fun onCreate() { * apiKey: Your API key string for SDK initialization. * domain: The default domain to use for URL shortening. -#### Create Short URL +### πŸ’‘ How It Works + +The app demonstrates: + +#### βœ… Generating Short Links ```kotlin import com.github.shortiosdk.ShortioSdk @@ -131,6 +135,8 @@ thread { } } ``` +**Note**: Deprecated: `shortenUrl`(apiKey, params) is still supported for backward compatibility but is no longer recommended for use. Use shortenUrl(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: @@ -138,7 +144,7 @@ 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`) | +| `domain` | `String` | βœ… | Your Short.io domain (e.g., `example.short.gy`) | | `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 | @@ -315,8 +321,10 @@ CoroutineScope(Dispatchers.IO).launch { try { val res = ShortioSdk.trackConversion( "https://{your_domain}/", - conversionId = null + "your_clid" + conversionId = null ) + /// all parameters are optional // conversionId can be 'signup', 'purchase', 'download', etc. Log.d("Handle Conversion Tracking", "Handle Conversion Tracking: $res") } catch (e: Exception) { 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 dbbd7eb..b32a4b6 100644 --- a/ShortIOSDK/src/main/java/com/github/shortiosdk/Helpers/HelperMethods.kt +++ b/ShortIOSDK/src/main/java/com/github/shortiosdk/Helpers/HelperMethods.kt @@ -12,3 +12,18 @@ fun extractClidFromUrl(urlString: String): String? { null } } + +fun removeUtmParams(url: String): String { + val uri = Uri.parse(url) + val builder = uri.buildUpon().clearQuery() + + uri.queryParameterNames + .filter { !it.startsWith("utm_", ignoreCase = true) } + .forEach { key -> + uri.getQueryParameters(key)?.forEach { value -> + builder.appendQueryParameter(key, value) + } + } + + return builder.build().toString() +} \ No newline at end of file diff --git a/ShortIOSDK/src/main/java/com/github/shortiosdk/ShortIO.kt b/ShortIOSDK/src/main/java/com/github/shortiosdk/ShortIO.kt index 4f7bf68..9e17322 100644 --- a/ShortIOSDK/src/main/java/com/github/shortiosdk/ShortIO.kt +++ b/ShortIOSDK/src/main/java/com/github/shortiosdk/ShortIO.kt @@ -9,8 +9,10 @@ import com.github.shortiosdk.Helpers.StringOrIntSerializer import android.util.Base64 import android.util.Log 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.URI import java.net.URL import java.net.URLEncoder @@ -19,10 +21,14 @@ 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 { - var apiKey: String = "" + var storedApiKey: String = "" var savedDestinationUrl: String = "" var savedDomain: String? = null var storedClid: String = "" @@ -36,7 +42,7 @@ object ShortioSdk { */ fun initialize( apiKey: String, domain: String) { if (!isInitialized){ - this.apiKey = apiKey + this.storedApiKey = apiKey this.savedDomain = domain println("SDK initialized with API key: $apiKey") isInitialized = true @@ -50,13 +56,96 @@ object ShortioSdk { */ fun isSdkInitialized(): Boolean { if (!isInitialized) { - throw IllegalStateException("SDK is not initialized. Please call initialize before using the SDK.") + throw IllegalStateException("SDK is not initialized. Please initialize the SDk before using it.") } return true } /** - * Create ShortUrl by using shortenUrl Method + * 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. + */ + @Deprecated( + message = "Use shortenUrl(parameters) instead", + replaceWith = ReplaceWith("shortenUrl(parameters)"), + level = DeprecationLevel.WARNING + ) + fun shortenUrl( + deprecatedApiKey: String? = null, + parameters: ShortIOParameters + ): ShortIOResult { + val gson = GsonBuilder() + .registerTypeAdapter(StringOrInt::class.java, StringOrIntSerializer()) + .create() + if (parameters.domain.isNullOrBlank()){ + val errorModel = ShortIOErrorModel( + message = "Domain Not Provided", + statusCode = 400, + code = "MALFORMED_SUCCESS", + success = false + ) + return ShortIOResult.Error(errorModel) + } + var apiKey = if (!deprecatedApiKey.isNullOrBlank()){ + deprecatedApiKey + } else{ + storedApiKey + } + val client = OkHttpClient() + val mediaType = "application/json".toMediaType() + val jsonBody = gson.toJson(parameters) + val body = jsonBody.toRequestBody(mediaType) + + val request = Request.Builder() + .url(baseURL) + .post(body) + .addHeader("accept", "application/json") + .addHeader("content-type", "application/json") + .addHeader("authorization", apiKey) + .build() + + val response = client.newCall(request).execute() + val responseBody = response.body?.string() + + return if (response.isSuccessful) { + val model = responseBody?.let { gson.fromJson(it, ShortIOResponseModel::class.java) } + if (model != null) { + ShortIOResult.Success(model) + } else { + val errorModel = ShortIOErrorModel( + message = "Empty or malformed success response", + statusCode = response.code, + code = "MALFORMED_SUCCESS", + success = false + ) + ShortIOResult.Error(errorModel) + } + } else { + val errorModel = try { + responseBody?.let { + gson.fromJson(it, ShortIOErrorModel::class.java)?.copy(statusCode = response.code) + } ?: ShortIOErrorModel( + message = "Unknown error", + statusCode = response.code, + code = "UNKNOWN", + success = false + ) + } catch (e: Exception) { + ShortIOErrorModel( + message = "Malformed error response: ${e.localizedMessage}", + statusCode = response.code, + code = "INVALID_JSON", + success = false + ) + } + return ShortIOResult.Error(errorModel) + } + } + + /** + * 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. */ fun shortenUrl( @@ -69,6 +158,7 @@ object ShortioSdk { if (parameters.domain.isNullOrBlank()) { parameters.domain = savedDomain!! } + val client = OkHttpClient() val mediaType = "application/json".toMediaType() val jsonBody = gson.toJson(parameters) @@ -79,7 +169,7 @@ object ShortioSdk { .post(body) .addHeader("accept", "application/json") .addHeader("content-type", "application/json") - .addHeader("authorization", apiKey) + .addHeader("authorization", storedApiKey) .build() val response = client.newCall(request).execute() @@ -133,13 +223,15 @@ object ShortioSdk { val host = uri.host ?: return null val shortioClidUrl = withContext(Dispatchers.IO) { - HandleClick(uri.toString()) - } - if (shortioClidUrl != null) { - savedDestinationUrl = shortioClidUrl + handleClick(uri.toString()) } storedClid = shortioClidUrl.let { it?.let { urlString -> extractClidFromUrl(urlString) } ?: "" } + val destinationUrl = shortioClidUrl?.let { removeUtmParams(it) } + if (destinationUrl != null) { + savedDestinationUrl = destinationUrl + } + return UrlComponents( scheme = scheme, host = host, @@ -147,37 +239,46 @@ object ShortioSdk { query = uri.encodedQuery, fragment = uri.fragment, fullUrl = uri.toString(), - destinationUrl = shortioClidUrl + destinationUrl = destinationUrl ) } /** - * HandleClick() is used to track the click + * handleClick() is used to track the click * Parameters: It takes uriString of type String as parameter. * Returns String */ - suspend private fun HandleClick(uriString: String): String? = withContext(Dispatchers.IO) { - isSdkInitialized() - try { + suspend fun handleClick(uriString: String): String? { + return try { val urlString = when { uriString.contains("utm_medium=android", ignoreCase = true) -> uriString uriString.contains("?") -> "$uriString&utm_medium=android" else -> "$uriString?utm_medium=android" } - val client = OkHttpClient.Builder() - .followRedirects(false) - .build() + val url = URL(urlString) + val connection = withContext(Dispatchers.IO) { + url.openConnection() as HttpURLConnection + } - val request = Request.Builder() - .url(URL(urlString)) - .head() - .build() + connection.requestMethod = "HEAD" + connection.instanceFollowRedirects = false - client.newCall(request).execute().use { response -> + connection.connect() - response.header("Location") + println("Response Headers:") + for ((key, value) in connection.headerFields) { + if (key != null && value != null) { + println(" $key: ${value.joinToString()}") + } } + + val redirectedUrl = connection.getHeaderField("Location") + + connection.disconnect() + + redirectedUrl ?: "Not Found" + } catch (e: Exception) { println("Network error: ${e.localizedMessage}") null @@ -190,7 +291,6 @@ object ShortioSdk { * Returns: SecureResult which includes securedOriginalURL and securedShortUrl */ fun createSecure(originalURL: String): SecureResult { - isSdkInitialized() return try { val keyGenerator = KeyGenerator.getInstance("AES") @@ -232,7 +332,6 @@ object ShortioSdk { clid: String? = null, conversionId: String? = null ): Boolean = withContext(Dispatchers.IO) { - isSdkInitialized() try { val originalURLString = if (originalURL.isNullOrEmpty()){ savedDestinationUrl From fc3a3f7bd40e9f6543d1ce1b9a6c48b950553149 Mon Sep 17 00:00:00 2001 From: rajpootathar Date: Mon, 18 Aug 2025 11:56:19 +0500 Subject: [PATCH 08/18] * updated and optimized the code --- README.md | 19 ++- .../java/com/github/shortiosdk/ShortIO.kt | 141 +++++------------- 2 files changed, 50 insertions(+), 110 deletions(-) diff --git a/README.md b/README.md index d1405df..05be3cb 100644 --- a/README.md +++ b/README.md @@ -52,7 +52,7 @@ It will be: ```kotlin dependencies { implementation("com.github.User:Repo:Tag") // Example - implementation("com.github.Short-io:android-sdk:v1.0.8") // Use this + implementation("com.github.Short-io:android-sdk:v1.0.9") // Use this } ``` ### 3. Sync the Project @@ -80,7 +80,7 @@ import com.github.shortiosdk.ShortioSdk ### πŸ”— SDK Usage -#### Initialization +#### 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. @@ -121,7 +121,7 @@ try { thread { try { - when (val result = ShortioSdk.shortenUrl(params)) { + when (val result = ShortioSdk.createShortLink(params)) { is ShortIOResult.Success -> { Log.d("ShortIOResult","Shortened URL: ${result.data.shortURL}") } @@ -135,7 +135,7 @@ thread { } } ``` -**Note**: Deprecated: `shortenUrl`(apiKey, params) is still supported for backward compatibility but is no longer recommended for use. Use shortenUrl(params) instead. +**Note**: Deprecated: `createShortLink`(apiKey, params) is still supported for backward compatibility but is no longer recommended for use. Use `createShortLink(params)` instead. ## πŸ“„ API Parameters @@ -144,8 +144,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. No longer required β€” inferred from API key. May be removed in future versions. | +| `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) | @@ -320,11 +320,10 @@ Track conversions for your short links to measure campaign effectiveness. The SD CoroutineScope(Dispatchers.IO).launch { try { val res = ShortioSdk.trackConversion( - "https://{your_domain}/", - "your_clid" - conversionId = null + domain: "https://{your_domain}", // ⚠️ Deprecated (optional): + clid: "your_clid", // ⚠️ Deprecated (optional): + conversionId: "your_conversionID" (optional) ) - /// all parameters are optional // conversionId can be 'signup', 'purchase', 'download', etc. Log.d("Handle Conversion Tracking", "Handle Conversion Tracking: $res") } catch (e: Exception) { diff --git a/ShortIOSDK/src/main/java/com/github/shortiosdk/ShortIO.kt b/ShortIOSDK/src/main/java/com/github/shortiosdk/ShortIO.kt index 9e17322..2de6bc3 100644 --- a/ShortIOSDK/src/main/java/com/github/shortiosdk/ShortIO.kt +++ b/ShortIOSDK/src/main/java/com/github/shortiosdk/ShortIO.kt @@ -13,7 +13,6 @@ import com.github.shortiosdk.Helpers.removeUtmParams import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import java.net.HttpURLConnection -import java.net.URI import java.net.URL import java.net.URLEncoder import java.nio.charset.StandardCharsets @@ -28,10 +27,9 @@ import kotlin.collections.joinToString object ShortioSdk { - var storedApiKey: String = "" - var savedDestinationUrl: String = "" - var savedDomain: String? = null - var storedClid: String = "" + var apiKey: String = "" + var domain: String = "" + var clid: String = "" var isInitialized: Boolean = false /** @@ -42,8 +40,8 @@ object ShortioSdk { */ fun initialize( apiKey: String, domain: String) { if (!isInitialized){ - this.storedApiKey = apiKey - this.savedDomain = domain + this.apiKey = apiKey + this.domain = domain println("SDK initialized with API key: $apiKey") isInitialized = true } else{ @@ -67,80 +65,18 @@ object ShortioSdk { * Parameters:- It takes ShortIOParameters as parameter which includes originalUrl, domain, clocking, password, title etc. It also apiKey. */ @Deprecated( - message = "Use shortenUrl(parameters) instead", - replaceWith = ReplaceWith("shortenUrl(parameters)"), + message = "Use createShortLink(parameters) instead", + replaceWith = ReplaceWith("createShortLink(parameters)"), level = DeprecationLevel.WARNING ) - fun shortenUrl( - deprecatedApiKey: String? = null, - parameters: ShortIOParameters + fun createShortLink( + parameters: ShortIOParameters, + apiKey: String? = null ): ShortIOResult { - val gson = GsonBuilder() - .registerTypeAdapter(StringOrInt::class.java, StringOrIntSerializer()) - .create() - if (parameters.domain.isNullOrBlank()){ - val errorModel = ShortIOErrorModel( - message = "Domain Not Provided", - statusCode = 400, - code = "MALFORMED_SUCCESS", - success = false - ) - return ShortIOResult.Error(errorModel) - } - var apiKey = if (!deprecatedApiKey.isNullOrBlank()){ - deprecatedApiKey - } else{ - storedApiKey - } - val client = OkHttpClient() - val mediaType = "application/json".toMediaType() - val jsonBody = gson.toJson(parameters) - val body = jsonBody.toRequestBody(mediaType) - - val request = Request.Builder() - .url(baseURL) - .post(body) - .addHeader("accept", "application/json") - .addHeader("content-type", "application/json") - .addHeader("authorization", apiKey) - .build() - - val response = client.newCall(request).execute() - val responseBody = response.body?.string() - - return if (response.isSuccessful) { - val model = responseBody?.let { gson.fromJson(it, ShortIOResponseModel::class.java) } - if (model != null) { - ShortIOResult.Success(model) - } else { - val errorModel = ShortIOErrorModel( - message = "Empty or malformed success response", - statusCode = response.code, - code = "MALFORMED_SUCCESS", - success = false - ) - ShortIOResult.Error(errorModel) - } - } else { - val errorModel = try { - responseBody?.let { - gson.fromJson(it, ShortIOErrorModel::class.java)?.copy(statusCode = response.code) - } ?: ShortIOErrorModel( - message = "Unknown error", - statusCode = response.code, - code = "UNKNOWN", - success = false - ) - } catch (e: Exception) { - ShortIOErrorModel( - message = "Malformed error response: ${e.localizedMessage}", - statusCode = response.code, - code = "INVALID_JSON", - success = false - ) - } - return ShortIOResult.Error(errorModel) - } + return performCreateShortLink( + parameters = parameters, + apiKey = apiKey.toString() + ) } /** @@ -148,15 +84,21 @@ object ShortioSdk { * Create ShortUrl by using shortenUrl(parameters: ShortIOParameters) Method * Parameters:- It takes ShortIOParameters as parameter which includes originalUrl, domain, clocking, password, title etc. */ - fun shortenUrl( + fun createShortLink( parameters: ShortIOParameters ): ShortIOResult { - isSdkInitialized() + return performCreateShortLink( + parameters = parameters, + apiKey = ShortioSdk.apiKey + ) + } + + private fun performCreateShortLink(parameters: ShortIOParameters, apiKey: String): ShortIOResult { val gson = GsonBuilder() .registerTypeAdapter(StringOrInt::class.java, StringOrIntSerializer()) .create() if (parameters.domain.isNullOrBlank()) { - parameters.domain = savedDomain!! + parameters.domain = domain } val client = OkHttpClient() @@ -169,7 +111,7 @@ object ShortioSdk { .post(body) .addHeader("accept", "application/json") .addHeader("content-type", "application/json") - .addHeader("authorization", storedApiKey) + .addHeader("authorization", apiKey) .build() val response = client.newCall(request).execute() @@ -225,12 +167,9 @@ object ShortioSdk { val shortioClidUrl = withContext(Dispatchers.IO) { handleClick(uri.toString()) } - storedClid = shortioClidUrl.let { it?.let { urlString -> extractClidFromUrl(urlString) } ?: "" } + clid = shortioClidUrl.let { it?.let { urlString -> extractClidFromUrl(urlString) } ?: "" } val destinationUrl = shortioClidUrl?.let { removeUtmParams(it) } - if (destinationUrl != null) { - savedDestinationUrl = destinationUrl - } return UrlComponents( scheme = scheme, @@ -328,36 +267,38 @@ object ShortioSdk { * Returns: Result Boolean based on status code */ suspend fun trackConversion( - originalURL: String? = null, clid: String? = null, + domain: String? = null, conversionId: String? = null ): Boolean = withContext(Dispatchers.IO) { try { - val originalURLString = if (originalURL.isNullOrEmpty()){ - savedDestinationUrl - } else { - originalURL - } - val originalUri = URI(originalURLString) - val scheme = originalUri.scheme ?: return@withContext false - val host = originalUri.host ?: return@withContext false // Build query params val queryParams = mutableListOf>() if (!conversionId.isNullOrEmpty()) { queryParams.add("c" to conversionId) } - if (!clid.isNullOrEmpty()) { - queryParams.add("clid" to clid) - } else{ - queryParams.add("clid" to storedClid) + 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")}" } - val finalUrl = "$scheme://$host/.shortio/conversion?$queryString" + val finalUrl = "https://$finalDomain/.shortio/conversion?$queryString" val client = OkHttpClient() From 3ddc276c21c254a849fd7674fe64c792e4beb8ec Mon Sep 17 00:00:00 2001 From: rajpootathar Date: Mon, 25 Aug 2025 16:11:33 +0500 Subject: [PATCH 09/18] * updated the docs about getting the original url --- README.md | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 05be3cb..8377d77 100644 --- a/README.md +++ b/README.md @@ -274,6 +274,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: @@ -283,19 +285,31 @@ override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) lifecycleScope.launch { val result = ShortioSdk.handleIntent(intent) - Log.d("New Intent", "Host: ${result?.host}, Path: ${result?.path}, DestinationURL: ${result?.destinationUrl}") + // 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}, DestinationURL: ${result?.destinationUrl}") + // Access the original URL + val originalUrl = result?.destinationUrl + Log.d("New Intent", + "Host: ${result?.host}, + Path: ${result?.path}, + Original URL: $originalUrl" + ) } } ``` From 607f4b9dfd7c9c4630e036adc2e87f2a005a9404 Mon Sep 17 00:00:00 2001 From: rajpootathar Date: Mon, 31 Aug 2026 14:30:11 +0500 Subject: [PATCH 10/18] fix: correct utm tagging, threading and error reporting - utm_medium was appended by string concatenation keyed on contains("?"), so a URL with a fragment and no query had the parameter placed inside the fragment and it never reached the server. Build it with Uri instead. - handleClick wrapped only openConnection() in Dispatchers.IO; connect() and the header reads ran on the caller's thread. Wrap the whole body. - trackConversion built "https:///..." with no domain and let OkHttp's IllegalArgumentException escape. Guard and return false. - The deprecated createShortLink overload stringified a null apiKey and sent the literal "null" as the authorization header. - handleClick returned the sentinel "Not Found" for a missing Location header, which handleIntent then exposed as destinationUrl. Return null. - Route println and printStackTrace through Log. - Add an internal seam so the HTTP client can be replaced in tests. --- .../shortiosdk/Helpers/HelperMethods.kt | 3 +- .../java/com/github/shortiosdk/ShortIO.kt | 70 +++++++++++-------- 2 files changed, 41 insertions(+), 32 deletions(-) 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 b32a4b6..5207b04 100644 --- a/ShortIOSDK/src/main/java/com/github/shortiosdk/Helpers/HelperMethods.kt +++ b/ShortIOSDK/src/main/java/com/github/shortiosdk/Helpers/HelperMethods.kt @@ -1,6 +1,7 @@ package com.github.shortiosdk.Helpers import android.net.Uri +import android.util.Log fun extractClidFromUrl(urlString: String): String? { @@ -8,7 +9,7 @@ fun extractClidFromUrl(urlString: String): String? { val uri = Uri.parse(urlString) uri.getQueryParameter("clid") } catch (e: Exception) { - e.printStackTrace() + Log.e("ShortioSdk", "could not read clid from $urlString", e) null } } diff --git a/ShortIOSDK/src/main/java/com/github/shortiosdk/ShortIO.kt b/ShortIOSDK/src/main/java/com/github/shortiosdk/ShortIO.kt index 2de6bc3..fcb546f 100644 --- a/ShortIOSDK/src/main/java/com/github/shortiosdk/ShortIO.kt +++ b/ShortIOSDK/src/main/java/com/github/shortiosdk/ShortIO.kt @@ -5,9 +5,11 @@ import okhttp3.MediaType.Companion.toMediaType import okhttp3.RequestBody.Companion.toRequestBody import com.google.gson.GsonBuilder import android.content.Intent +import android.net.Uri import com.github.shortiosdk.Helpers.StringOrIntSerializer 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 @@ -32,6 +34,18 @@ object ShortioSdk { 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: @@ -42,10 +56,9 @@ object ShortioSdk { if (!isInitialized){ this.apiKey = apiKey this.domain = domain - println("SDK initialized with API key: $apiKey") isInitialized = true } else{ - Log.d("SDK Initialization","SDK is already Initialzed") + Log.d("SDK Initialization","SDK is already Initialized") } } @@ -63,6 +76,7 @@ object ShortioSdk { * 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", @@ -75,7 +89,7 @@ object ShortioSdk { ): ShortIOResult { return performCreateShortLink( parameters = parameters, - apiKey = apiKey.toString() + apiKey = apiKey ?: ShortioSdk.apiKey ) } @@ -83,6 +97,7 @@ object ShortioSdk { * 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 @@ -101,7 +116,6 @@ object ShortioSdk { parameters.domain = domain } - val client = OkHttpClient() val mediaType = "application/json".toMediaType() val jsonBody = gson.toJson(parameters) val body = jsonBody.toRequestBody(mediaType) @@ -114,7 +128,7 @@ object ShortioSdk { .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) { @@ -155,7 +169,7 @@ object ShortioSdk { /** * handleIntent() method is used handle the intent and it returns UrlComponents * Parameters: intent of type Intent - * Returns: UrlComponents which includes scheme, host, path, destibnationUrl, etc. + * Returns: UrlComponents which includes scheme, host, path, destinationUrl, etc. */ suspend fun handleIntent(intent: Intent): UrlComponents? { val uri = intent.data ?: return null @@ -185,41 +199,32 @@ object ShortioSdk { /** * handleClick() is used to track the click * Parameters: It takes uriString of type String as parameter. - * Returns String + * Returns the redirect target, or null if there is none or the request failed. */ - suspend fun handleClick(uriString: String): String? { - return try { - val urlString = when { - uriString.contains("utm_medium=android", ignoreCase = true) -> uriString - uriString.contains("?") -> "$uriString&utm_medium=android" - else -> "$uriString?utm_medium=android" + suspend fun handleClick(uriString: String): String? = withContext(Dispatchers.IO) { + try { + val uri = Uri.parse(uriString) + val urlString = if (uri.getQueryParameter("utm_medium") != null) { + uriString + } else { + uri.buildUpon().appendQueryParameter("utm_medium", "android").build().toString() } - val url = URL(urlString) - val connection = withContext(Dispatchers.IO) { - url.openConnection() as HttpURLConnection - } + val connection = URL(urlString).openConnection() as HttpURLConnection connection.requestMethod = "HEAD" connection.instanceFollowRedirects = false connection.connect() - println("Response Headers:") - for ((key, value) in connection.headerFields) { - if (key != null && value != null) { - println(" $key: ${value.joinToString()}") - } - } - val redirectedUrl = connection.getHeaderField("Location") connection.disconnect() - redirectedUrl ?: "Not Found" + redirectedUrl } catch (e: Exception) { - println("Network error: ${e.localizedMessage}") + Log.e("ShortioSdk", "handleClick failed", e) null } } @@ -255,7 +260,7 @@ object ShortioSdk { SecureResult(securedOriginalURL, securedShortUrl) } catch (e: Exception) { - e.printStackTrace() + Log.e("ShortioSdk", "createSecure failed", e) throw e } } @@ -298,20 +303,23 @@ object ShortioSdk { "${it.first}=${URLEncoder.encode(it.second, "UTF-8")}" } - val finalUrl = "https://$finalDomain/.shortio/conversion?$queryString" + if (finalDomain.isNullOrEmpty()) { + Log.e("ShortioSdk", "trackConversion needs a domain; call initialize() first") + return@withContext false + } - val client = OkHttpClient() + val finalUrl = "https://$finalDomain/.shortio/conversion?$queryString" val request = Request.Builder() .url(finalUrl) .get() .build() - client.newCall(request).execute().use { response -> + httpClient.newCall(request).execute().use { response -> return@withContext response.isSuccessful } } catch (e: Exception) { - e.printStackTrace() + Log.e("ShortioSdk", "createSecure failed", e) throw e } } From 21a6c51bdd7139f12744594f39740eb2cad7b5a9 Mon Sep 17 00:00:00 2001 From: rajpootathar Date: Mon, 31 Aug 2026 14:30:22 +0500 Subject: [PATCH 11/18] test: add unit test suite for ShortioSdk Replaces the Android Studio placeholder with 23 tests across four classes. createShortLink and trackConversion are faked through an OkHttp interceptor; handleClick and handleIntent use MockWebServer, since handleClick goes through HttpURLConnection and an interceptor cannot observe it. Robolectric supplies Uri, Base64, Log and Intent. Every test was checked by mutating the source and confirming the suite fails, so the coverage is verified rather than assumed. --- ShortIOSDK/build.gradle.kts | 8 + .../github/shortiosdk/CreateSecureTests.kt | 41 ++++ .../com/github/shortiosdk/ExampleUnitTest.kt | 17 -- .../com/github/shortiosdk/HandleClickTests.kt | 97 +++++++++ .../github/shortiosdk/HandleIntentTests.kt | 69 ++++++ .../com/github/shortiosdk/ShortIOSdkTests.kt | 203 ++++++++++++++++++ .../com/github/shortiosdk/StubInterceptor.kt | 56 +++++ .../src/test/resources/robolectric.properties | 1 + gradle/libs.versions.toml | 7 + 9 files changed, 482 insertions(+), 17 deletions(-) create mode 100644 ShortIOSDK/src/test/java/com/github/shortiosdk/CreateSecureTests.kt delete mode 100644 ShortIOSDK/src/test/java/com/github/shortiosdk/ExampleUnitTest.kt create mode 100644 ShortIOSDK/src/test/java/com/github/shortiosdk/HandleClickTests.kt create mode 100644 ShortIOSDK/src/test/java/com/github/shortiosdk/HandleIntentTests.kt create mode 100644 ShortIOSDK/src/test/java/com/github/shortiosdk/ShortIOSdkTests.kt create mode 100644 ShortIOSDK/src/test/java/com/github/shortiosdk/StubInterceptor.kt create mode 100644 ShortIOSDK/src/test/resources/robolectric.properties diff --git a/ShortIOSDK/build.gradle.kts b/ShortIOSDK/build.gradle.kts index c3d0d7c..6c7b56e 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/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..dcbd11b --- /dev/null +++ b/ShortIOSDK/src/test/java/com/github/shortiosdk/HandleClickTests.kt @@ -0,0 +1,97 @@ +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 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..acef478 --- /dev/null +++ b/ShortIOSDK/src/test/java/com/github/shortiosdk/ShortIOSdkTests.kt @@ -0,0 +1,203 @@ +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 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" } From 7e30415779eddd41f7c4245d42092c4dc1066189 Mon Sep 17 00:00:00 2001 From: rajpootathar Date: Mon, 31 Aug 2026 14:30:22 +0500 Subject: [PATCH 12/18] ci: add GitHub Actions workflow and pin the JitPack JDK Runs the unit tests and lint, compiles the instrumented tests without an emulator, and rehearses the JitPack publish so a broken release surfaces in the PR rather than at tag time. JitPack publishes with -xtest -xlint, so nothing else gates a release on the suite. jitpack.yml pins JDK 17; JitPack otherwise starts on 11, finds AGP needs 17 and downloads a toolchain mid-build. Also ignores .idea and .kotlin, and untracks the .idea files already committed. --- .github/jitpack-publish.init.gradle | 21 +++++ .github/workflows/ci.yml | 119 ++++++++++++++++++++++++++++ .gitignore | 8 +- README.md | 2 + jitpack.yml | 3 + 5 files changed, 147 insertions(+), 6 deletions(-) create mode 100644 .github/jitpack-publish.init.gradle create mode 100644 .github/workflows/ci.yml create mode 100644 jitpack.yml 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..c09ca57 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,119 @@ +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 + + - 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 + + - 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 + + - 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; } 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 8377d77..b5b8c4a 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 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 From 795ffa4518f0500acf8be166f0e45bae1d07d404 Mon Sep 17 00:00:00 2001 From: rajpootathar Date: Mon, 31 Aug 2026 14:51:09 +0500 Subject: [PATCH 13/18] fix: reject createShortLink with no API key instead of calling the API Without initialize(), the authorization header went out empty and the caller got a 401 that gave no hint the real problem was a missing initialize(). Return a NOT_INITIALIZED error before any request. Checks the resolved key rather than isInitialized, so passing an explicit key without calling initialize() still works. Returns an error rather than throwing, matching how the function already reports failures. --- .../src/main/java/com/github/shortiosdk/ShortIO.kt | 11 +++++++++++ .../java/com/github/shortiosdk/ShortIOSdkTests.kt | 10 ++++++++++ 2 files changed, 21 insertions(+) diff --git a/ShortIOSDK/src/main/java/com/github/shortiosdk/ShortIO.kt b/ShortIOSDK/src/main/java/com/github/shortiosdk/ShortIO.kt index fcb546f..1e63783 100644 --- a/ShortIOSDK/src/main/java/com/github/shortiosdk/ShortIO.kt +++ b/ShortIOSDK/src/main/java/com/github/shortiosdk/ShortIO.kt @@ -109,6 +109,17 @@ object ShortioSdk { } 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() diff --git a/ShortIOSDK/src/test/java/com/github/shortiosdk/ShortIOSdkTests.kt b/ShortIOSDK/src/test/java/com/github/shortiosdk/ShortIOSdkTests.kt index acef478..4d968df 100644 --- a/ShortIOSDK/src/test/java/com/github/shortiosdk/ShortIOSdkTests.kt +++ b/ShortIOSDK/src/test/java/com/github/shortiosdk/ShortIOSdkTests.kt @@ -149,6 +149,16 @@ class ShortIOSdkTests { 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 outOfRangeParametersAreRejectedAtConstruction() { assertThrows(IllegalArgumentException::class.java) { From ca29ffde71a1789f20de7b2951f9810d84e9377c Mon Sep 17 00:00:00 2001 From: rajpootathar Date: Mon, 31 Aug 2026 15:26:58 +0500 Subject: [PATCH 14/18] fix: address review feedback - trackConversion logged "createSecure failed" in its catch block. - trackConversion rethrew on transport errors, which defeats its Boolean return. Return false, matching the empty-domain guard. - handleIntent wrapped handleClick in Dispatchers.IO, which handleClick now does itself. - Correct the "SDk" typo in the not-initialized message, the Swift-style KDoc on trackConversion, and three README errors: the deprecated overload argument order, the domain parameter row, and a conversion tracking example that did not compile. --- README.md | 23 ++++++++----------- .../java/com/github/shortiosdk/ShortIO.kt | 12 ++++------ .../com/github/shortiosdk/ShortIOSdkTests.kt | 8 +++++++ 3 files changed, 23 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index b5b8c4a..a9164f5 100644 --- a/README.md +++ b/README.md @@ -137,7 +137,7 @@ thread { } } ``` -**Note**: Deprecated: `createShortLink`(apiKey, params) is still supported for backward compatibility but is no longer recommended for use. Use `createShortLink(params)` instead. +**Note**: Deprecated: `createShortLink(params, apiKey)` is still supported for backward compatibility but is no longer recommended for use. Use `createShortLink(params)` instead. ## πŸ“„ API Parameters @@ -146,7 +146,7 @@ The `ShortIOParameters` struct is used to define the details of the short link y | Parameter | Type | Required | Description | | ------------------- | ----------- | -------- | ------------------------------------------------------------ | -| `domain` | `String` | βœ… (Deprecated) | Your Short.io domain (e.g., `example.short.gy`). ⚠️ Deprecated. No longer required β€” inferred from API key. May be removed in future versions. | +| `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 | @@ -334,17 +334,14 @@ Track conversions for your short links to measure campaign effectiveness. The SD ```kotlin CoroutineScope(Dispatchers.IO).launch { - try { - val res = ShortioSdk.trackConversion( - domain: "https://{your_domain}", // ⚠️ Deprecated (optional): - clid: "your_clid", // ⚠️ Deprecated (optional): - conversionId: "your_conversionID" (optional) - ) - // conversionId can be 'signup', 'purchase', 'download', etc. - Log.d("Handle Conversion Tracking", "Handle Conversion Tracking: $res") - } catch (e: Exception) { - Log.e("Handle Conversion Tracking", "Error calling trackConversion", e) - } + // 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") } ``` diff --git a/ShortIOSDK/src/main/java/com/github/shortiosdk/ShortIO.kt b/ShortIOSDK/src/main/java/com/github/shortiosdk/ShortIO.kt index 1e63783..5bafd5d 100644 --- a/ShortIOSDK/src/main/java/com/github/shortiosdk/ShortIO.kt +++ b/ShortIOSDK/src/main/java/com/github/shortiosdk/ShortIO.kt @@ -67,7 +67,7 @@ object ShortioSdk { */ fun isSdkInitialized(): Boolean { if (!isInitialized) { - throw IllegalStateException("SDK is not initialized. Please initialize the SDk before using it.") + throw IllegalStateException("SDK is not initialized. Please initialize the SDK before using it.") } return true } @@ -189,9 +189,7 @@ object ShortioSdk { val host = uri.host ?: return null - val shortioClidUrl = withContext(Dispatchers.IO) { - handleClick(uri.toString()) - } + val shortioClidUrl = handleClick(uri.toString()) clid = shortioClidUrl.let { it?.let { urlString -> extractClidFromUrl(urlString) } ?: "" } val destinationUrl = shortioClidUrl?.let { removeUtmParams(it) } @@ -278,7 +276,7 @@ object ShortioSdk { /** * trackConversion() method is used to track the conversion. - * parameters: originalURL: String, clid: String, conversionId: String? = nil + * parameters: clid: String? = null, domain: String? = null, conversionId: String? = null * conversionId can be 'signup', 'purchase', 'download', etc. * Returns: Result Boolean based on status code */ @@ -330,8 +328,8 @@ object ShortioSdk { return@withContext response.isSuccessful } } catch (e: Exception) { - Log.e("ShortioSdk", "createSecure failed", e) - throw e + Log.e("ShortioSdk", "trackConversion failed", e) + return@withContext false } } } diff --git a/ShortIOSDK/src/test/java/com/github/shortiosdk/ShortIOSdkTests.kt b/ShortIOSDK/src/test/java/com/github/shortiosdk/ShortIOSdkTests.kt index 4d968df..9223214 100644 --- a/ShortIOSDK/src/test/java/com/github/shortiosdk/ShortIOSdkTests.kt +++ b/ShortIOSDK/src/test/java/com/github/shortiosdk/ShortIOSdkTests.kt @@ -159,6 +159,14 @@ class ShortIOSdkTests { 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) { From b557f46dcc973f5a18a891b2bd8d3e3b50e75972 Mon Sep 17 00:00:00 2001 From: rajpootathar Date: Mon, 31 Aug 2026 15:31:19 +0500 Subject: [PATCH 15/18] fix: restore source compatibility for shortenUrl and UrlComponents Both were broken against main by earlier releases. Cheap to restore, so restore them: - shortenUrl comes back as a deprecated alias for baseURL. - UrlComponents.destinationUrl gets a null default, so callers that constructed it before the field existed still compile. handleIntent stays suspend. It performs a network call, so the signature is correct, and a blocking overload would invite main-thread work. --- .../java/com/github/shortiosdk/Constants.kt | 5 +++- .../github/shortiosdk/Model/UrlComponents.kt | 2 +- .../shortiosdk/BackwardCompatibilityTests.kt | 26 +++++++++++++++++++ 3 files changed, 31 insertions(+), 2 deletions(-) create mode 100644 ShortIOSDK/src/test/java/com/github/shortiosdk/BackwardCompatibilityTests.kt diff --git a/ShortIOSDK/src/main/java/com/github/shortiosdk/Constants.kt b/ShortIOSDK/src/main/java/com/github/shortiosdk/Constants.kt index 88003dc..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 baseURL = "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/Model/UrlComponents.kt b/ShortIOSDK/src/main/java/com/github/shortiosdk/Model/UrlComponents.kt index 791fae9..73bff55 100644 --- a/ShortIOSDK/src/main/java/com/github/shortiosdk/Model/UrlComponents.kt +++ b/ShortIOSDK/src/main/java/com/github/shortiosdk/Model/UrlComponents.kt @@ -7,5 +7,5 @@ data class UrlComponents( val query: String?, val fragment: String?, val fullUrl: String, - val destinationUrl: String? + val destinationUrl: String? = null ) \ No newline at end of file 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) + } +} From fd1e63963406df7cb9772395a9e6adb655304503 Mon Sep 17 00:00:00 2001 From: rajpootathar Date: Mon, 31 Aug 2026 15:55:26 +0500 Subject: [PATCH 16/18] chore: add trailing newlines and stop checkout persisting credentials Checkout leaves a repo-scoped token in .git/config for the rest of the job; nothing here pushes, so it does not need one. --- .github/workflows/ci.yml | 6 ++++++ ShortIOSDK/build.gradle.kts | 2 +- .../java/com/github/shortiosdk/Helpers/HelperMethods.kt | 2 +- .../main/java/com/github/shortiosdk/Model/UrlComponents.kt | 2 +- 4 files changed, 9 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c09ca57..e1555e1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,6 +27,8 @@ jobs: timeout-minutes: 20 steps: - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + with: + persist-credentials: false - uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1 with: @@ -68,6 +70,8 @@ jobs: timeout-minutes: 20 steps: - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + with: + persist-credentials: false - uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1 with: @@ -87,6 +91,8 @@ jobs: timeout-minutes: 20 steps: - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + with: + persist-credentials: false - uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1 with: diff --git a/ShortIOSDK/build.gradle.kts b/ShortIOSDK/build.gradle.kts index 6c7b56e..1e973c9 100644 --- a/ShortIOSDK/build.gradle.kts +++ b/ShortIOSDK/build.gradle.kts @@ -50,4 +50,4 @@ dependencies { 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/Helpers/HelperMethods.kt b/ShortIOSDK/src/main/java/com/github/shortiosdk/Helpers/HelperMethods.kt index 5207b04..8c9ffd6 100644 --- a/ShortIOSDK/src/main/java/com/github/shortiosdk/Helpers/HelperMethods.kt +++ b/ShortIOSDK/src/main/java/com/github/shortiosdk/Helpers/HelperMethods.kt @@ -27,4 +27,4 @@ fun removeUtmParams(url: String): String { } return builder.build().toString() -} \ 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 73bff55..5a6cba7 100644 --- a/ShortIOSDK/src/main/java/com/github/shortiosdk/Model/UrlComponents.kt +++ b/ShortIOSDK/src/main/java/com/github/shortiosdk/Model/UrlComponents.kt @@ -8,4 +8,4 @@ data class UrlComponents( val fragment: String?, val fullUrl: String, val destinationUrl: String? = null -) \ No newline at end of file +) From ad1fb4065e0eb8bacc2b96230d93ae83b0d7071f Mon Sep 17 00:00:00 2001 From: rajpootathar Date: Mon, 31 Aug 2026 16:00:08 +0500 Subject: [PATCH 17/18] ci: report a check named test The main ruleset requires a status check called test; the jobs above report under their own names, so nothing satisfied it and PRs stayed blocked. --- .github/workflows/ci.yml | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e1555e1..aae6e3c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -123,3 +123,17 @@ jobs: || { 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" ] From 20e18bdd6e4c7c45d8e35d10f0627abfd37cb2bc Mon Sep 17 00:00:00 2001 From: rajpootathar Date: Mon, 31 Aug 2026 16:22:11 +0500 Subject: [PATCH 18/18] fix: keep tagging android on links that carry another utm_medium The Uri rewrite skipped tagging whenever any utm_medium was present, so a campaign link (utm_medium=cpc) stopped being marked android. The original skipped only when already tagged android; restore that. --- .../src/main/java/com/github/shortiosdk/ShortIO.kt | 3 ++- .../java/com/github/shortiosdk/HandleClickTests.kt | 12 ++++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/ShortIOSDK/src/main/java/com/github/shortiosdk/ShortIO.kt b/ShortIOSDK/src/main/java/com/github/shortiosdk/ShortIO.kt index 5bafd5d..ca07d0d 100644 --- a/ShortIOSDK/src/main/java/com/github/shortiosdk/ShortIO.kt +++ b/ShortIOSDK/src/main/java/com/github/shortiosdk/ShortIO.kt @@ -213,7 +213,8 @@ object ShortioSdk { suspend fun handleClick(uriString: String): String? = withContext(Dispatchers.IO) { try { val uri = Uri.parse(uriString) - val urlString = if (uri.getQueryParameter("utm_medium") != null) { + val alreadyTagged = uri.getQueryParameter("utm_medium").equals("android", ignoreCase = true) + val urlString = if (alreadyTagged) { uriString } else { uri.buildUpon().appendQueryParameter("utm_medium", "android").build().toString() diff --git a/ShortIOSDK/src/test/java/com/github/shortiosdk/HandleClickTests.kt b/ShortIOSDK/src/test/java/com/github/shortiosdk/HandleClickTests.kt index dcbd11b..1683c42 100644 --- a/ShortIOSDK/src/test/java/com/github/shortiosdk/HandleClickTests.kt +++ b/ShortIOSDK/src/test/java/com/github/shortiosdk/HandleClickTests.kt @@ -58,6 +58,18 @@ class HandleClickTests { 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"))