diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..2accbed --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,85 @@ +name: CI + +on: + workflow_dispatch: + pull_request: + push: + branches: [main, short-io-app] + +# 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: + JAVA_VERSION: "17" + +jobs: + # Compose tests run under Robolectric; they never click the two buttons that + # would call the live Short.io API. + build: + name: build + runs-on: ubuntu-latest + # Otherwise a wedged daemon runs to the 360-minute default. + timeout-minutes: 20 + steps: + - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + with: + persist-credentials: false + + - uses: actions/setup-java@c5195efecf7bdfc987ee8bae7a71cb8b11521c00 # v4.7.1 + with: + distribution: temurin + java-version: ${{ env.JAVA_VERSION }} + + - uses: gradle/actions/setup-gradle@ac638b010cf58a27ee6c972d7336334ccaf61c96 # v4.4.1 + + # Split out so a JitPack outage reads as a dependency failure, not a compile failure. + # 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: Resolve dependencies + run: ./gradlew --no-daemon :app:dependencies --configuration debugRuntimeClasspath + + - name: Assemble + run: ./gradlew --no-daemon assembleDebug + + - name: Unit tests + run: ./gradlew --no-daemon testDebugUnitTest + + # Type-checks androidTest without an emulator. + - name: Compile instrumented tests + run: ./gradlew --no-daemon assembleAndroidTest + + # Lint is not run: android:host="{your_domain}" is an intentional placeholder + # that lint reports as an AppLinkUrlError. + + - name: Upload reports + if: failure() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: build-reports + path: | + app/build/reports/ + app/build/test-results/ + retention-days: 7 + + # The ruleset requires a check named `test`; the job above reports as `build`. + # always() so a failed build still reports this check. + test: + name: test + needs: build + if: always() + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - run: '[ "${{ needs.build.result }}" = "success" ]' diff --git a/.gitignore b/.gitignore index aa724b7..06500f5 100644 --- a/.gitignore +++ b/.gitignore @@ -1,15 +1,11 @@ *.iml .gradle /local.properties -/.idea/caches -/.idea/libraries -/.idea/modules.xml -/.idea/workspace.xml -/.idea/navEditor.xml -/.idea/assetWizardSettings.xml .DS_Store /build /captures .externalNativeBuild .cxx local.properties +.kotlin +.idea diff --git a/README.md b/README.md index d6e4ada..dfc7b2e 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,8 @@ # 📱 ShortIOApp – Android Sample Project for ShortIOSDK +[![CI](https://github.com/Short-io/android-sdk-example/actions/workflows/ci.yml/badge.svg)](https://github.com/Short-io/android-sdk-example/actions/workflows/ci.yml) + **ShortIOApp** is a sample Android project that demonstrates how to integrate and use the [ShortIOSDK](https://github.com/Short-io/android-sdk.git) for generating short links and handling deep links using [Short.io](https://short.io/). This project helps developers understand how to: @@ -28,13 +30,25 @@ Open Android Studio, and open the `android-sdk-example` folder in Android Studio ## 🛠 Setup Instructions -### 1. Add Your API Key -Open the Main Activity file and replace the placeholder with your Short.io Public API Key: +### Initialize the SDK -```bash -val apiKey = "your_api_key" +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) ////Replace with your Short.io API KEY and Domain in Constants File +} ``` +* apiKey: Your API key string for initialization. +* domain: The default domain to use for URL shortening. + + + + ### 🔗 Need help finding your API key? @@ -46,7 +60,6 @@ In your MainActivity file replace the placeholder with your Short.io domain and ```kotlin val params = ShortIOParameters( - domain = "your_domain", // e.g., example.short.gy originalURL = "https://{your_domain}" // The destination URL ) ``` @@ -60,14 +73,12 @@ The app demonstrates: Using your domain and original URL, you can generate a short link like this: ```kotlin -val apiKey = "your_api_key" val params = ShortIOParameters( - domain = "your_domain", // e.g., example.short.gy originalURL = "https://{your_domain}" // The destination URL ) -when (val result = ShortioSdk.shortenUrl(apiKey, params)) { +when (val result = ShortioSdk.createShortLink(params)) { is ShortIOResult.Success -> { val shortUrl = result.data.shortURL Log.d("ShortIO", "Shortened URL: $shortUrl") @@ -78,6 +89,40 @@ when (val result = ShortioSdk.shortenUrl(apiKey, params)) { } } ``` +**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. + +### 🔐 Secure Short Link + +If you want to encrypt the original URL, the SDK provides a `createSecure` function that uses AES-GCM encryption. + +```kotlin +val originalURL = "your_original_URL" +val result = ShortioSdk.createSecure(originalURL) +Log.d("SecureURL", "RESULT: ${result}") +Log.d("securedOriginalURL", "URL: ${result.securedOriginalURL}") +Log.d("securedShortUrl", "URL: ${result.securedShortUrl}") +``` + +### 🔄 Conversion Tracking + +Track conversions for your short links to measure campaign effectiveness. The SDK provides a simple method to record conversions. + +```kotlin +CoroutineScope(Dispatchers.IO).launch { + 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) + } +} +``` + ## 🤖 Deep Linking Setup To handle deep links via Short.io on Android, you'll need to set up Android App Links properly using your domain's Digital Asset Links and intent filters. @@ -153,25 +198,45 @@ 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: ```kotlin +import androidx.lifecycle.lifecycleScope +import kotlinx.coroutines.launch + override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) - val result = ShortioSdk.handleIntent(intent) - Log.d("New Intent", "Host: ${result?.host}, Path: ${result?.path}") + lifecycleScope.launch { + val result = ShortioSdk.handleIntent(intent) + // Access the original URL + val originalUrl = result?.destinationUrl + Log.d("New Intent", + "Host: ${result?.host}, + Path: ${result?.path}, + Original URL: $originalUrl" + ) + } } ``` 3. In the same activity, you can also handle the initial intent inside the `onCreate()` method: ```kotlin -// Optional override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + lifecycleScope.launch { val result = ShortioSdk.handleIntent(intent) - Log.d("New Intent", "Host: ${result?.host}, Path: ${result?.path}") + // Access the original URL + val originalUrl = result?.destinationUrl + Log.d("New Intent", + "Host: ${result?.host}, + Path: ${result?.path}, + Original URL: $originalUrl" + ) + } } ``` diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 293b176..c5ae43a 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -37,6 +37,11 @@ android { buildFeatures { compose = true } + testOptions { + unitTests { + isIncludeAndroidResources = true + } + } } dependencies { @@ -49,8 +54,11 @@ dependencies { implementation(libs.androidx.ui.graphics) implementation(libs.androidx.ui.tooling.preview) implementation(libs.androidx.material3) - implementation("com.github.Short-io:android-sdk:v1.0.5") + implementation("com.github.Short-io:android-sdk:v1.0.9") testImplementation(libs.junit) + testImplementation(libs.robolectric) + testImplementation(platform(libs.androidx.compose.bom)) + testImplementation(libs.androidx.ui.test.junit4) androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.espresso.core) androidTestImplementation(platform(libs.androidx.compose.bom)) diff --git a/app/src/main/java/com/github/shortioapp/Constants.kt b/app/src/main/java/com/github/shortioapp/Constants.kt new file mode 100644 index 0000000..b224e11 --- /dev/null +++ b/app/src/main/java/com/github/shortioapp/Constants.kt @@ -0,0 +1,8 @@ +package com.github.shortioapp + + +//Replace with your Short.io API KEY +val apiKey = "YOUR_API_KEY" + +//Replace with your Short.io domain +val domain = "YOUR_DOMAIN" \ No newline at end of file diff --git a/app/src/main/java/com/github/shortioapp/MainActivity.kt b/app/src/main/java/com/github/shortioapp/MainActivity.kt index 2261505..cecce6e 100644 --- a/app/src/main/java/com/github/shortioapp/MainActivity.kt +++ b/app/src/main/java/com/github/shortioapp/MainActivity.kt @@ -34,15 +34,19 @@ import android.content.ClipData import android.content.ClipboardManager import android.content.Context import android.widget.Toast +import androidx.lifecycle.lifecycleScope +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch class MainActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + ShortioSdk.initialize( apiKey, domain) //Replace with your Short.io API KEY and Domain in Constants File enableEdgeToEdge() - val apiKey = "your_api_key" - setContent { ShortIOAppTheme { Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding -> @@ -58,7 +62,13 @@ class MainActivity : ComponentActivity() { Spacer(modifier = Modifier.height(16.dp)) - LinkShorteningButton(apiKey = apiKey) + LinkShorteningButton() + + Spacer(modifier = Modifier.height(16.dp)) + CreateSecureUrlButton() + + Spacer(modifier = Modifier.height(16.dp)) + TrackConversionButton() } } } @@ -67,13 +77,15 @@ class MainActivity : ComponentActivity() { override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) - val response = ShortioSdk.handleIntent(intent) - Log.d("New Intent", "Host: ${response?.host}, Path: ${response?.path}") + lifecycleScope.launch { + val result = ShortioSdk.handleIntent(intent) + Log.d("New Intent", "Host: ${result?.host}, Path: ${result?.path}, DestinationURL: ${result?.destinationUrl}") + } } } @Composable -fun LinkShorteningButton(apiKey: String) { +fun LinkShorteningButton() { var isLoading by remember { mutableStateOf(false) } var resultMessage by remember { mutableStateOf(null) } var isError by remember { mutableStateOf(false) } @@ -88,11 +100,10 @@ fun LinkShorteningButton(apiKey: String) { thread { try { val params = ShortIOParameters( - originalURL = "https://{your_domain}", - domain = "your_domain" + originalURL = "https://{YOUR_DOMAIN}/", ) - when (val result = ShortioSdk.shortenUrl(apiKey, params)) { + when (val result = ShortioSdk.createShortLink(params)) { is ShortIOResult.Success -> { val shortUrl = result.data.shortURL Log.d("ShortIO", "Shortened URL: $shortUrl") @@ -171,6 +182,140 @@ fun LinkShorteningButton(apiKey: String) { } } +@Composable +fun CreateSecureUrlButton() { + var resultMessage by remember { mutableStateOf(null) } + var isLoading by remember { mutableStateOf(false) } + val context = LocalContext.current + + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Button( + onClick = { + isLoading = true + resultMessage = null + thread { + try { + val originalUrl = "https://{your_domain}" + val secure = ShortioSdk.createSecure(originalUrl) + + // The ciphertext is what gets shortened; the key never reaches the server. + val params = ShortIOParameters(originalURL = secure.securedOriginalURL) + when (val result = ShortioSdk.createShortLink(params)) { + is ShortIOResult.Success -> { + // securedShortUrl is the "#" fragment the browser decrypts with. + val secureUrl = result.data.shortURL + secure.securedShortUrl + (context as ComponentActivity).runOnUiThread { + isLoading = false + resultMessage = "Secure URL: $secureUrl" + } + } + is ShortIOResult.Error -> { + Log.e("SecureURL", "Error: ${result.data.message}") + (context as ComponentActivity).runOnUiThread { + isLoading = false + resultMessage = "Error: ${result.data.message}" + } + } + } + } catch (e: Exception) { + Log.e("SecureURL", "Exception: ${e.message}", e) + (context as ComponentActivity).runOnUiThread { + isLoading = false + resultMessage = "Error: ${e.message}" + } + } + } + }, + enabled = !isLoading + ) { + Text(text = if (isLoading) "Generating..." else "Create Secure Short Link") + } + + if (isLoading) { + Spacer(modifier = Modifier.height(16.dp)) + CircularProgressIndicator() + } + + resultMessage?.let { + Spacer(modifier = Modifier.height(16.dp)) + if (it.startsWith("Error")) { + Text( + text = it, + fontSize = 16.sp, + color = Color.Red, + modifier = Modifier.padding(horizontal = 8.dp) + ) + } else { + val secureUrl = it.substringAfter("Secure URL:").trim() + + Text( + text = secureUrl, + fontSize = 16.sp, + color = Color(0xFF4CAF50), + modifier = Modifier.padding(horizontal = 8.dp) + ) + + Spacer(modifier = Modifier.height(8.dp)) + + Button( + onClick = { + val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + val clip = ClipData.newPlainText("secureURL", secureUrl) + clipboard.setPrimaryClip(clip) + Toast.makeText(context, "Secure URL copied to clipboard", Toast.LENGTH_SHORT).show() + } + ) { + Text("Copy Secure URL") + } + } + } + } +} + +@Composable +fun TrackConversionButton() { + var conversionResult by remember { mutableStateOf(null) } + var errorMessage by remember { mutableStateOf(null) } + + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Button( + onClick = { + CoroutineScope(Dispatchers.IO).launch { + try { + val res = ShortioSdk.trackConversion( + "your_clid", // ⚠️ Deprecated (optional) + "your_domain", // ⚠️ Deprecated (optional) + "your_conversionID" //(optional) + ) + conversionResult = res + errorMessage = null + } catch (e: Exception) { + conversionResult = null + errorMessage = e.message + Log.e("Handle Conversion Tracking", "Error calling trackConversion", e) + } + } + } + ) { + Text(text = "Conversion Tracking") + } + conversionResult?.let { success -> + Text( + text = if (success) "Conversion successful" else "Conversion failed", + color = if (success) Color(0xFF4CAF50) else Color.Red + ) + } + errorMessage?.let { + Text( + text = it, + color = Color.Red + ) + } + } +} @Composable fun LinkShortnerTitle() { diff --git a/app/src/test/java/com/github/shortioapp/ExampleUnitTest.kt b/app/src/test/java/com/github/shortioapp/ExampleUnitTest.kt deleted file mode 100644 index 56a97e5..0000000 --- a/app/src/test/java/com/github/shortioapp/ExampleUnitTest.kt +++ /dev/null @@ -1,17 +0,0 @@ -package com.github.shortioapp - -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/app/src/test/java/com/github/shortioapp/MainActivityTests.kt b/app/src/test/java/com/github/shortioapp/MainActivityTests.kt new file mode 100644 index 0000000..03a92a8 --- /dev/null +++ b/app/src/test/java/com/github/shortioapp/MainActivityTests.kt @@ -0,0 +1,51 @@ +package com.github.shortioapp + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import com.github.shortiosdk.ShortioSdk +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +/** + * Only createSecure is exercised here; it is pure local crypto. The other two + * buttons call the live Short.io API and must never be clicked from a test. + */ +@RunWith(RobolectricTestRunner::class) +class MainActivityTests { + + @get:Rule + val compose = createAndroidComposeRule() + + @Test + fun everyFeatureIsOfferedOnLaunch() { + compose.onNodeWithText("Short Link Generator").assertIsDisplayed() + compose.onNodeWithText("Create Short Link").assertIsDisplayed() + compose.onNodeWithText("Create Secure Short Link").assertIsDisplayed() + compose.onNodeWithText("Conversion Tracking").assertIsDisplayed() + } + + @Test + fun launchingInitializesTheSdkFromConstants() { + assertTrue(ShortioSdk.isSdkInitialized()) + compose.onNodeWithText("Short Link Generator").assertIsDisplayed() + } + + @Test + fun noResultIsShownBeforeAnyButtonIsPressed() { + compose.onNodeWithText("Copy URL").assertDoesNotExist() + compose.onNodeWithText("Copy Secure URL").assertDoesNotExist() + } + + @Test + fun creatingASecureUrlRendersTheKeyAndACopyButton() { + compose.onNodeWithText("Create Secure Short Link").performClick() + compose.waitForIdle() + + compose.onNodeWithText("Copy Secure URL").assertIsDisplayed() + } +} diff --git a/app/src/test/resources/robolectric.properties b/app/src/test/resources/robolectric.properties new file mode 100644 index 0000000..979b5ee --- /dev/null +++ b/app/src/test/resources/robolectric.properties @@ -0,0 +1 @@ +sdk=34 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 6cb5d1c..4ec5eca 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -8,6 +8,7 @@ espressoCore = "3.6.1" lifecycleRuntimeKtx = "2.9.1" activityCompose = "1.10.1" composeBom = "2024.09.00" +robolectric = "4.14.1" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } @@ -24,6 +25,7 @@ androidx-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-toolin androidx-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" } androidx-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" } androidx-material3 = { group = "androidx.compose.material3", name = "material3" } +robolectric = { group = "org.robolectric", name = "robolectric", version.ref = "robolectric" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" }