ShoppingGate Travel SDK For Android Native
Android Native Integration Guide (AAR + Native Facade).
Complete technical guide for embedding the ShoppingGate Travel SDK into native Android apps.
SDK Downloads
Compiled AAR artifacts, engine binaries, and required assets.
Android Studio starter project with implementation.
1. Prerequisites
Verification of the following environment settings is required for a successful build:
- JDK 17 Required
- Android Studio Ladybug (2024.2.1+)
- Min SDK: API 24, Target SDK: 35, Compile SDK: 36
2. API & Required Parameters
To initialize the SDK, you must provide the following required payload values:
● countryCode: Example: +91, +966.
● environment: development or production.
● language: en or ar.
● auth_code: Generated from the authentication API (expires in 5 minutes).
● host_id: Your application identifier (e.g., "barq").
3. Repository Setup
Place the ShoppingGateSDK folder in your project's root directory and keep
the native helper file in host app source.
├── app/
├── ShoppingGateSDK/ <-- ShoppingGate SDK folder (AAR repo)
4. Host Configuration
A. Settings Gradle
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
// Local ShoppingGate SDK
maven {
url = uri("${rootProject.projectDir}/ShoppingGateSDK")
}
// Flutter engine binaries
maven {
url = uri("https://storage.googleapis.com/download.flutter.io")
}
}
}
B. App-Level Gradle
android {
compileSdk = 36
defaultConfig {
minSdk = 24
targetSdk = 35
multiDexEnabled = true
}
compileOptions {
isCoreLibraryDesugaringEnabled = true
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
}
kotlinOptions {
jvmTarget = "11"
}
}
dependencies {
debugImplementation("com.shoppingate.sdk.travel:flutter_release:1.0")
releaseImplementation("com.shoppingate.sdk.travel:flutter_release:1.0")
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.1.5")
}
5. Android Manifest Registry
Register the Flutter host activity. Add Google Maps key if your flow uses maps.
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION"/>
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION"/>
<application>
<!-- Optional: Required when map screens are used -->
<meta-data
android:name="com.google.android.geo.API_KEY"
android:value="YOUR_MAPS_KEY_HERE"/>
<activity
android:name="io.flutter.embedding.android.FlutterActivity"
android:exported="false"
android:launchMode="singleTop"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize" />
</application>
</manifest>
6. Launching the SDK
Use the SDK native facade helper (SgTravelNative). Do not implement MethodChannel wiring directly in partner app.
Testing Credentials:
Use the following details for your sandbox environment:
phoneNumber: 554433220
countryCode: +966
auth_code: Generate using the authentication API endpoint below
Note: You must call the auth code generation API before initializing the SDK. See Authentication Flow section for details.
To enable user login via auth code (without OTP verification), follow the steps below:
- Step 1: Generate Auth Code
Call the ShoppingGate API from your backend to generate an auth code. This auth code is required for silent login without OTP verification.API Requestcurl -X 'POST' \ 'https://microservices.shoppinggate.app/users/users/sdk/generate-auth-code' \ -H 'accept: */*' \ -H 'Content-Type: application/json' \ -d '{ "host_id": "barq", "host_secret": "barq-secret", "phone_number": "554433221", "phone_code": "+966", "device_id": "string" }'API Response{ "status": true, "message": "Auth code generated successfully", "data": { "auth_code": "5bd9f0fb-0c7a-4250-8b44-0b35e5a1a129", "expires_in": 300 } } - Step 2: Pass Auth Code to SDK
Once you receive theauth_codefrom the API response, pass it to the SDK during initialization along with yourhost_id. The SDK will perform a silent login without requiring OTP verification.Kotlin ExampleSgTravelNative.initialize( request = SgTravelNative.SgTravelRequest( phoneNumber = "554433220", countryCode = "+966", environment = "development", language = "en", auth_code = "5bd9f0fb-0c7a-4250-8b44-0b35e5a1a129", // From API response host_id = "barq" // Your host identifier ) )Note: The auth code expires in 300 seconds (5 minutes). Ensure you initialize the SDK within this timeframe.
A. Native Facade Helper
package com.sg.travelsdkdemo
import android.app.Activity
import android.content.Context
import android.content.Intent
import io.flutter.FlutterInjector
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.embedding.engine.FlutterEngineCache
import io.flutter.embedding.engine.dart.DartExecutor
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import java.lang.ref.WeakReference
object SgTravelNative {
const val CHANNEL_NAME: String = "shoppingate_travel_sdk/channel"
private const val ENGINE_ID: String = "sg_travel_sdk_engine"
// Native-to-Flutter methods.
const val METHOD_INITIALIZE: String = "initialize"
const val METHOD_OPEN_BOOKING_FLOW: String = "openBookingFlow"
const val METHOD_OPEN_ORDER_FLOW: String = "openOrderFlow"
// Flutter-to-native methods.
const val METHOD_HOST_READY: String = "hostReady"
const val METHOD_SET_USER_DATA: String = "setUserData"
const val METHOD_CLOSE_SDK: String = "closeSDK"
const val METHOD_CONTINUE_TO_PAYMENT: String = "continueToPayment"
// Native-to-Flutter payment method.
private const val METHOD_INVOKE_PAYMENT: String = "invokePaymentInitiation"
private const val ERROR_MISSING_INITIAL_DATA = "MISSING_INITIAL_DATA"
private const val ERROR_INVALID_ARGUMENT = "INVALID_ARGUMENT"
private const val ERROR_INVALID_ENVIRONMENT = "INVALID_ENVIRONMENT"
private const val KEY_PHONE = "phoneNumber"
private const val KEY_COUNTRY = "countryCode"
private const val KEY_ENV = "environment"
private const val KEY_LANG = "language"
private const val KEY_AUTH_CODE = "auth_code"
private const val KEY_HOST_ID = "host_id"
private val lock = Any()
private var latestData: MutableMap = mutableMapOf()
private var isInitialized: Boolean = false
private var isFlutterReady: Boolean = false
private var shouldDestroyEngineOnHostDestroy: Boolean = false
private var methodChannel: MethodChannel? = null
private val pendingCalls: MutableList = mutableListOf()
private var hostActivityRef: WeakReference? = null
private data class PaymentPayload(
val amount: Double,
val orderId: String?,
val requestPaymentType: String?,
)
private data class ChannelCall(
val method: String,
val arguments: Any?,
)
data class SgTravelRequest(
val phoneNumber: String,
val countryCode: String,
val environment: String,
val language: String,
val auth_code: String,
val host_id: String,
)
@JvmStatic
fun initialize(request: SgTravelRequest) {
val normalized = normalizeRequest(request)
synchronized(lock) {
latestData = normalized
isInitialized = true
}
dispatchOrQueue(
method = METHOD_INITIALIZE,
arguments = HashMap(normalized),
)
}
@JvmStatic
fun openBooking(context: Context) {
ensureInitialized()
val createdNewEngine = ensureEngineStarted(context)
if (createdNewEngine) {
dispatchInitialize()
}
launchHost(context)
dispatchOrQueue(method = METHOD_OPEN_BOOKING_FLOW, arguments = null)
}
@JvmStatic
fun openOrder(context: Context) {
ensureInitialized()
val createdNewEngine = ensureEngineStarted(context)
if (createdNewEngine) {
dispatchInitialize()
}
launchHost(context)
dispatchOrQueue(method = METHOD_OPEN_ORDER_FLOW, arguments = null)
}
@JvmStatic
fun setHostActivity(activity: Activity) {
synchronized(lock) { hostActivityRef = WeakReference(activity) }
}
@JvmStatic
fun clearHostActivity() {
synchronized(lock) { hostActivityRef = null }
}
@JvmStatic
fun invokePaymentInitiation(paymentResult: String) {
val (channel, ready) = synchronized(lock) {
Pair(methodChannel, isFlutterReady)
}
if (channel != null && ready) {
channel.invokeMethod(METHOD_INVOKE_PAYMENT, paymentResult)
}
}
@JvmStatic
fun attachChannel(engine: FlutterEngine) {
val channel = MethodChannel(engine.dartExecutor.binaryMessenger, CHANNEL_NAME)
synchronized(lock) {
methodChannel = channel
isFlutterReady = false
}
channel.setMethodCallHandler(::handleMethodCall)
flushPendingCallsIfReady()
}
private fun launchHost(context: Context) {
val intent = SgFlutterActivity
.withCachedEngine(ENGINE_ID)
.destroyEngineWithActivity(false)
.build(context)
if (context !is Activity) {
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
}
context.startActivity(intent)
}
private fun ensureEngineStarted(context: Context): Boolean {
if (FlutterEngineCache.getInstance().get(ENGINE_ID) != null) {
return false
}
val appContext = context.applicationContext
val loader = FlutterInjector.instance().flutterLoader()
loader.startInitialization(appContext)
loader.ensureInitializationComplete(appContext, emptyArray())
val engine = FlutterEngine(appContext)
registerPluginsSafely(engine)
attachChannel(engine)
val entrypoint = DartExecutor.DartEntrypoint(
loader.findAppBundlePath(),
"travelSdkHostEntryPoint",
)
engine.dartExecutor.executeDartEntrypoint(entrypoint)
FlutterEngineCache.getInstance().put(ENGINE_ID, engine)
return true
}
private fun dispatchInitialize() {
val payload = synchronized(lock) {
if (!isInitialized || latestData.isEmpty()) {
null
} else {
HashMap(latestData)
}
}
if (payload != null) {
dispatchOrQueue(
method = METHOD_INITIALIZE,
arguments = payload,
)
}
}
private fun registerPluginsSafely(engine: FlutterEngine) {
try {
val generatedPluginRegistrant = Class.forName("io.flutter.plugins.GeneratedPluginRegistrant")
val registrationMethod = generatedPluginRegistrant.getDeclaredMethod(
"registerWith",
FlutterEngine::class.java,
)
registrationMethod.invoke(null, engine)
} catch (_: Throwable) {
// Plugin auto-registration fallback varies by embedding configuration.
}
}
private fun handleMethodCall(call: MethodCall, result: MethodChannel.Result) {
when (call.method) {
METHOD_HOST_READY -> {
synchronized(lock) {
isFlutterReady = true
}
result.success(null)
flushPendingCallsIfReady()
}
METHOD_SET_USER_DATA -> {
val incoming = call.arguments as? Map<*, *>
if (incoming == null) {
result.error(ERROR_INVALID_ARGUMENT, "Expected map payload", null)
return
}
try {
val normalized = normalizePayload(incoming)
synchronized(lock) {
latestData = normalized
}
result.success(null)
} catch (e: ValidationException) {
result.error(e.code, e.message, null)
}
}
METHOD_CLOSE_SDK -> {
result.success(null)
val host = synchronized(lock) {
shouldDestroyEngineOnHostDestroy = true
hostActivityRef?.get()
}
if (host != null) {
host.runOnUiThread { host.finish() }
} else {
// No active host activity, so reset immediately.
resetEnginePreservingInitialization()
}
}
METHOD_CONTINUE_TO_PAYMENT -> {
val payload = parsePaymentPayload(call.arguments)
result.success(null)
val host = synchronized(lock) { hostActivityRef?.get() }
host?.runOnUiThread {
host.startActivity(
PaymentActivity.newIntent(
context = host,
amount = payload.amount,
orderId = payload.orderId,
requestPaymentType = payload.requestPaymentType,
),
)
}
}
else -> result.notImplemented()
}
}
private fun dispatchOrQueue(method: String, arguments: Any?) {
val activeChannel = synchronized(lock) {
if (methodChannel == null || !isFlutterReady) {
pendingCalls.add(ChannelCall(method = method, arguments = arguments))
null
} else {
methodChannel
}
}
activeChannel?.invokeMethod(method, arguments)
}
private fun parsePaymentPayload(arguments: Any?): PaymentPayload {
val mapPayload = arguments as? Map<*, *>
if (mapPayload != null) {
val amount = (mapPayload["total_amount"] as? Number)?.toDouble() ?: 0.0
val orderId = mapPayload["order_id"]?.toString()
val requestPaymentType = mapPayload["request_payment_type"]?.toString()
return PaymentPayload(
amount = amount,
orderId = orderId,
requestPaymentType = requestPaymentType,
)
}
val amount = (arguments as? Number)?.toDouble() ?: 0.0
return PaymentPayload(amount = amount, orderId = null, requestPaymentType = null)
}
private fun flushPendingCallsIfReady() {
val (channel, calls) = synchronized(lock) {
if (methodChannel == null || !isFlutterReady || pendingCalls.isEmpty()) {
return
}
val snapshot = pendingCalls.toList()
pendingCalls.clear()
Pair(methodChannel!!, snapshot)
}
calls.forEach { call ->
channel.invokeMethod(call.method, call.arguments)
}
}
private fun ensureInitialized() {
val initialized = synchronized(lock) { isInitialized }
if (!initialized) {
throw ValidationException(
ERROR_MISSING_INITIAL_DATA,
"Initialize request not found. Call SgTravelNative.initialize first.",
)
}
}
private fun normalizeRequest(request: SgTravelRequest): MutableMap {
val raw = mapOf(
KEY_PHONE to request.phoneNumber,
KEY_COUNTRY to request.countryCode,
KEY_ENV to request.environment,
KEY_LANG to request.language,
KEY_AUTH_CODE to request.auth_code,
KEY_HOST_ID to request.host_id,
)
return normalizePayload(raw)
}
private fun normalizePayload(raw: Map<*, *>): MutableMap {
val phone = readRequiredString(raw, KEY_PHONE)
val country = readRequiredString(raw, KEY_COUNTRY)
val env = readRequiredString(raw, KEY_ENV)
val language = readRequiredString(raw, KEY_LANG)
val authCode = readRequiredString(raw, KEY_AUTH_CODE)
val hostId = readRequiredString(raw, KEY_HOST_ID)
if (env != "development" && env != "production") {
throw ValidationException(
ERROR_INVALID_ENVIRONMENT,
"Use environment: development or production",
)
}
val normalized = mutableMapOf(
KEY_PHONE to phone,
KEY_COUNTRY to country,
KEY_ENV to env,
KEY_LANG to language,
KEY_AUTH_CODE to authCode,
KEY_HOST_ID to hostId,
)
return normalized
}
private fun readRequiredString(raw: Map<*, *>, key: String): String {
val value = raw[key]?.toString()?.trim().orEmpty()
if (value.isEmpty()) {
throw ValidationException(
ERROR_INVALID_ARGUMENT,
"Required keys: phoneNumber, countryCode, environment, language, auth_code, host_id",
)
}
return value
}
private class ValidationException(val code: String, message: String) : IllegalArgumentException(message)
@JvmStatic
fun onHostActivityDestroyed(activity: Activity) {
val shouldReset = synchronized(lock) {
val host = hostActivityRef?.get()
if (host == activity) {
hostActivityRef = null
}
val pending = shouldDestroyEngineOnHostDestroy
shouldDestroyEngineOnHostDestroy = false
pending
}
if (shouldReset) {
resetEnginePreservingInitialization()
}
}
private fun resetEnginePreservingInitialization() {
synchronized(lock) {
methodChannel?.setMethodCallHandler(null)
methodChannel = null
isFlutterReady = false
shouldDestroyEngineOnHostDestroy = false
pendingCalls.clear()
hostActivityRef = null
}
FlutterEngineCache.getInstance().get(ENGINE_ID)?.destroy()
FlutterEngineCache.getInstance().remove(ENGINE_ID)
}
@JvmStatic
fun destroy() {
synchronized(lock) {
methodChannel?.setMethodCallHandler(null)
methodChannel = null
isInitialized = false
isFlutterReady = false
shouldDestroyEngineOnHostDestroy = false
pendingCalls.clear()
latestData.clear()
hostActivityRef = null
}
FlutterEngineCache.getInstance().get(ENGINE_ID)?.destroy()
FlutterEngineCache.getInstance().remove(ENGINE_ID)
}
}
B. Create Flutter Activity
package com.sg.travelsdkdemo
import io.flutter.embedding.android.FlutterActivity
/**
* A thin [FlutterActivity] subclass that keeps [SgTravelNative] aware of the
* currently visible host activity.
*
* This allows [SgTravelNative] to:
* - Properly call [finish] when the SDK requests `closeSDK`.
* - Start the native [PaymentActivity] when the SDK requests `continueToPayment`.
*/
class SgFlutterActivity : FlutterActivity() {
override fun onResume() {
super.onResume()
SgTravelNative.setHostActivity(this)
}
override fun onDestroy() {
super.onDestroy()
SgTravelNative.onHostActivityDestroyed(this)
}
companion object {
fun withCachedEngine(engineId: String): CachedEngineIntentBuilder =
CachedEngineIntentBuilder(SgFlutterActivity::class.java, engineId)
}
}
C. Host Activity Usage
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
SgTravelNative.initialize(
request = SgTravelNative.SgTravelRequest(
phoneNumber = "554433220",
countryCode = "+966",
environment = "development", // or production
language = "en",
auth_code = "5bd9f0fb-0c7a-4250-8b44-0b35e5a1a129", // Generated from API
host_id = "barq", // Your host identifier
),
)
findViewById<Button>(R.id.openBooking).setOnClickListener {
SgTravelNative.openBooking(context = this)
}
findViewById<Button>(R.id.openOrder).setOnClickListener {
SgTravelNative.openOrder(context = this)
}
}
override fun onDestroy() {
super.onDestroy()
SgTravelNative.destroy()
}
}
D. Payment Screen UI
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@android:color/white"
android:fitsSystemWindows="true">
<androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/clContainer"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:padding="20dp">
<ImageView
android:id="@+id/btnClose"
android:layout_width="36dp"
android:layout_height="36dp"
android:src="@android:drawable/ic_menu_revert"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintStart_toStartOf="parent" />
<TextView
android:id="@+id/tvTitle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Payment Method"
android:textSize="18sp"
android:textStyle="bold"
app:layout_constraintTop_toTopOf="@id/btnClose"
app:layout_constraintBottom_toBottomOf="@id/btnClose"
app:layout_constraintStart_toEndOf="@id/btnClose" />
<TextView
android:id="@+id/tvAmount"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="18sp"
android:textStyle="bold"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent" />
<Button
android:id="@+id/btnPay"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Pay Now"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.constraintlayout.widget.ConstraintLayout>
E. Payment Screen Logic
package com.sg.travelsdkdemo
import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.widget.Button
import android.widget.ImageView
import android.widget.TextView
import androidx.activity.enableEdgeToEdge
import androidx.appcompat.app.AppCompatActivity
/**
* Native payment screen shown when the Travel SDK invokes `continueToPayment`.
*
* Displays the booking amount and lets the user initiate payment. On confirming,
* it calls [SgTravelNative.invokePaymentInitiation] to signal back to the SDK.
*/
class PaymentActivity : AppCompatActivity() {
private var isPaymentInitiated = false
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContentView(R.layout.activity_payment)
val amount = intent.getDoubleExtra(EXTRA_AMOUNT, 0.0)
val orderId = intent.getStringExtra(EXTRA_ORDER_ID)
val tvAmount = findViewById(R.id.tvAmount)
val btnPay = findViewById(R.id.btnPay)
val btnClose = findViewById(R.id.btnClose)
tvAmount.text = getString(R.string.payment_amount_label, amount)
btnPay.setOnClickListener {
if (!isPaymentInitiated) {
isPaymentInitiated = true
btnPay.isEnabled = false
btnPay.text = getString(R.string.payment_initiated_label)
val transactionId = buildTransactionId(orderId)
SgTravelNative.invokePaymentInitiation(transactionId)
// Automatically close the payment screen after payment initiation
btnClose.postDelayed({ finish() }, 300)
}
}
btnClose.setOnClickListener {
finish()
}
}
companion object {
private const val EXTRA_AMOUNT = "extra_amount"
private const val EXTRA_ORDER_ID = "extra_order_id"
private const val EXTRA_REQUEST_PAYMENT_TYPE = "extra_request_payment_type"
fun newIntent(
context: Context,
amount: Double,
orderId: String?,
requestPaymentType: String?,
): Intent =
Intent(context, PaymentActivity::class.java)
.putExtra(EXTRA_AMOUNT, amount)
.putExtra(EXTRA_ORDER_ID, orderId)
.putExtra(EXTRA_REQUEST_PAYMENT_TYPE, requestPaymentType)
private fun buildTransactionId(orderId: String?): String {
val timestamp = System.currentTimeMillis()
return if (!orderId.isNullOrBlank()) {
"TXN_${orderId}_$timestamp"
} else {
"TXN_$timestamp"
}
}
}
}
7. Troubleshooting
- Initialize First: Call initialize before openBooking/openOrder.
- Environment Validation: Use only development or production.
- No Direct MethodChannel: Keep partner app on SgTravelNative facade API only.
- Maps Issues: Ensure API key is enabled for Maps SDK and restricted with package/SHA.