Skip to main content

Android SDK Examples & Troubleshooting

Complete implementation examples and solutions to common integration challenges.

Quickstart

The full lifecycle, stripped of UI:

import androidx.lifecycle.lifecycleScope
import com.doorstepai.sdks.tracking.DoorstepAI
import com.doorstepai.sdks.tracking.DropoffType
import kotlinx.coroutines.launch

// Once at app startup
DoorstepAI.init(
context = this,
notificationTitle = "Tracking...",
notificationText = "Tracking your delivery"
) { result ->
result.fold(
onSuccess = { DoorstepAI.setAPIKey(API_KEY) },
onFailure = { /* handle init failure */ }
)
}

// Per delivery, driven by your geofence
DoorstepAI.startDeliveryByAddressString(
address = "123 Main St, Apt 4B, San Francisco, CA 94102",
deliveryId = "delivery_12345",
coordinates = LatLngObject(lat = 37.7749, lng = -122.4194)
) { /* result handler */ }

// markDropoff is a suspend function; call it from a coroutine scope
lifecycleScope.launch {
DoorstepAI.markDropoff(
deliveryId = "delivery_12345",
dropoffType = DropoffType.POD
)
}

DoorstepAI.stopDelivery("delivery_12345")

Geofencing Quickstart

Minimal usage of the in-SDK geofencing API: Start Geofencing, Update Stops, Mark Drop-off, Stop Geofencing, plus the optional Resume After Relaunch. See Using the SDK → In-SDK Geofencing for the full API reference:

import androidx.lifecycle.lifecycleScope
import com.doorstepai.sdks.tracking.DeliveryStop
import com.doorstepai.sdks.tracking.DoorstepAI
import com.doorstepai.sdks.tracking.RouteGeofenceOptions
import kotlinx.coroutines.launch

// Once at app startup (after resuming any persisted state)
DoorstepAI.resumeRouteGeofencingIfNeeded()

// Build the day's stops
val todaysStops = listOf(
DeliveryStop(
deliveryId = "delivery_12345",
address = "123 Main St, Apt 4B, San Francisco, CA 94102",
latitude = 37.7749,
longitude = -122.4194
),
DeliveryStop(
deliveryId = "delivery_67890",
address = "500 Market St, San Francisco, CA 94105",
latitude = 37.7899,
longitude = -122.4001,
radiusMeters = 150.0 // optional per-stop override
)
)

// Hand the SDK the day's stops (options are optional; defaults shown)
DoorstepAI.startRouteGeofencing(
stops = todaysStops,
options = RouteGeofenceOptions(defaultRadiusMeters = 250.0)
) { result ->
result.fold(
onSuccess = { Log.i("DoorstepAI", "Geofencing started") },
onFailure = { error -> Log.e("DoorstepAI", "Failed to start geofencing: ${error.message}") }
)
}

// Observe session start/stop events to drive UI
lifecycleScope.launch {
DoorstepAI.geofenceSessionEvents.collect { event ->
Log.i("DoorstepAI", "Geofencing event: ${event.deliveryId} -> ${event.type} (${event.reason})")
}
}

// Driver takes a POD at a stop; gates the auto-stop on geofence EXIT
lifecycleScope.launch {
DoorstepAI.markDropoff(deliveryId = "delivery_12345", dropoffType = DropoffType.POD)
}

// Stops change mid-day (added/removed): pass the full updated stop list
DoorstepAI.updateRouteStops(stops = todaysStops.drop(1)) { /* result handler */ }

// End of shift
DoorstepAI.stopRouteGeofencing()

Staged permission requests

Ask for exactly the buckets you need, when you need them. Background location (LOCATION_ALWAYS) is always a second, separate request — Android 11+ auto-denies a background-location ask that is bundled with the foreground one. Full reference: Permissions.

OnboardingActivity.kt
import com.doorstepai.sdks.tracking.DoorstepAI
import com.doorstepai.sdks.tracking.DoorstepPermission
import com.doorstepai.sdks.tracking.DoorstepPermissionState

// Stage 1 — foreground location, notifications and the Nearby-devices group
DoorstepAI.requestPermissions(
this,
setOf(
DoorstepPermission.LOCATION_WHEN_IN_USE,
DoorstepPermission.NOTIFICATIONS,
DoorstepPermission.BLUETOOTH_SCAN
)
)

// Stage 2 — after your own "why background" screen, and only once stage 1 is granted
if (DoorstepAI.checkPermission(this, DoorstepPermission.LOCATION_WHEN_IN_USE).state ==
DoorstepPermissionState.GRANTED
) {
DoorstepAI.requestBackgroundLocationPermission(this)
}

// Alternative to stage 1 — every bucket in one call. LOCATION_ALWAYS still lands in
// result.deferred, so stage 2 above is still required.
val result = DoorstepAI.requestAllPermissions(this)
if (!result.didRequest) {
// Nothing was asked (all granted, unavailable or deferred): no dialog was shown, so
// onRequestPermissionsResult will never fire. Don't await it.
}

Forward every answer to the SDK, or DENIED can never be told from PERMANENTLY_DENIED:

OnboardingActivity.kt
override fun onRequestPermissionsResult(
requestCode: Int,
permissions: Array<String>,
grantResults: IntArray
) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
DoorstepAI.notePermissionRequestResult(this, permissions, grantResults)
}
tip

Call requestAllPermissions(activity) rather than a bare requestPermissions(activity): while the deprecated includeBluetooth overload still exists, the no-whitelist call is an overload-resolution ambiguity in Kotlin. Passing an explicit whitelist also resolves cleanly.

Full test-app component

The component below wires those calls to a Jetpack Compose UI for manual testing:

Show full component
MainActivity.kt
package com.doorstepai.doorstepsdktestapp

import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.doorstepai.doorstepsdktestapp.ui.theme.DoorstepSDKTestAppTheme
import com.doorstepai.sdks.tracking.DoorstepAI
import com.doorstepai.sdks.tracking.AddressType
import com.doorstepai.sdks.tracking.DropoffType
import com.doorstepai.sdks.tracking.DoorstepPermission
import com.doorstepai.sdks.tracking.DoorstepPermissionState
import kotlinx.coroutines.launch

// Permission handling below uses the SDK's whitelist API — no helper class to copy.
// Buckets, states and the staged flow are documented at /Android/permissions.
private const val PERMISSION_RATIONALE =
"DoorstepAI needs location access to track deliveries. Allow location \"While using the " +
"app\" first; background location is a separate second request."

class MainActivity : ComponentActivity() {
companion object {
private const val FIXED_API_TOKEN = "api_key" // Replace with your actual API token
}

private var permissionsGranted by mutableStateOf(false)
private var backgroundLocationGranted by mutableStateOf(false)
private var sdkInitialized by mutableStateOf(false)

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
checkPermissionsAndInitialize()

setContent {
DoorstepSDKTestAppTheme {
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
DoorstepSDKTestScreen(
context = this,
permissionsGranted = permissionsGranted,
backgroundLocationGranted = backgroundLocationGranted,
sdkInitialized = sdkInitialized,
modifier = Modifier.padding(innerPadding)
)
}
}
}
}

// The OS delivers grant results to the activity, not to the SDK — forwarding them is the
// only way checkPermissions can report PERMANENTLY_DENIED instead of the re-askable DENIED.
override fun onRequestPermissionsResult(
requestCode: Int,
permissions: Array<String>,
grantResults: IntArray
) {
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
DoorstepAI.notePermissionRequestResult(this, permissions, grantResults)
checkPermissionsAndInitialize()
}

/** Stage 1: foreground location, notifications, Nearby devices. Never background location. */
fun requestForegroundPermissions() {
val result = DoorstepAI.requestPermissions(
this,
setOf(
DoorstepPermission.LOCATION_WHEN_IN_USE,
DoorstepPermission.NOTIFICATIONS,
DoorstepPermission.BLUETOOTH_SCAN
)
)
// Empty `requested` means no dialog was shown, so no result callback will arrive.
if (!result.didRequest) checkPermissionsAndInitialize()
}

/** Stage 2: background location, asked alone, only after stage 1 was granted. */
fun requestBackgroundLocation() {
if (!DoorstepAI.requestBackgroundLocationPermission(this)) {
// Refused to ask: below API 29, already granted, or foreground location is missing.
checkPermissionsAndInitialize()
}
}

fun checkPermissionsAndInitialize() {
val states = DoorstepAI.checkPermissions(
this,
setOf(DoorstepPermission.LOCATION_WHEN_IN_USE, DoorstepPermission.LOCATION_ALWAYS)
)
permissionsGranted =
states[DoorstepPermission.LOCATION_WHEN_IN_USE]?.state == DoorstepPermissionState.GRANTED
// WHEN_IN_USE_ONLY is not granted: background collection and route geofencing
// do not work in that state, so never collapse it into GRANTED.
backgroundLocationGranted =
states[DoorstepPermission.LOCATION_ALWAYS]?.state == DoorstepPermissionState.GRANTED

if (permissionsGranted && !sdkInitialized) {
initializeSDK()
}
}

private fun initializeSDK() {
try {
DoorstepAI.init(
context = this,
notificationTitle = "Tracking...",
notificationText = "Tracking your delivery"
) { result ->
result.fold(
onSuccess = {
try {
DoorstepAI.setAPIKey(FIXED_API_TOKEN)
sdkInitialized = true
} catch (e: Exception) {
sdkInitialized = false
}
},
onFailure = { error ->
sdkInitialized = false
}
)
}
} catch (e: Exception) {
sdkInitialized = false
}
}
}

@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DoorstepSDKTestScreen(
context: MainActivity,
permissionsGranted: Boolean,
backgroundLocationGranted: Boolean,
sdkInitialized: Boolean,
modifier: Modifier = Modifier
) {
val scope = rememberCoroutineScope()
var statusText by remember { mutableStateOf("Checking permissions...") }
var currentDeliveryId by remember { mutableStateOf("") }
var placeId by remember { mutableStateOf("") }
var eventName by remember { mutableStateOf("") }
var isDeliveryActive by remember { mutableStateOf(false) }
var activeDeliveryType by remember { mutableStateOf("") }

// Address fields
var streetNumber by remember { mutableStateOf("") }
var route by remember { mutableStateOf("") }
var subPremise by remember { mutableStateOf("") }
var locality by remember { mutableStateOf("") }
var administrativeArea by remember { mutableStateOf("") }
var postalCode by remember { mutableStateOf("") }

// Stage 1 fires automatically; the answer lands in MainActivity.onRequestPermissionsResult,
// which forwards it to the SDK and refreshes this state. Background location is stage 2,
// behind its own button below — bundling the two is auto-denied on Android 11+.
LaunchedEffect(permissionsGranted) {
if (!permissionsGranted) {
context.requestForegroundPermissions()
}
}

LaunchedEffect(permissionsGranted, backgroundLocationGranted, sdkInitialized) {
statusText = when {
!permissionsGranted -> "Requesting permissions for DoorstepAI SDK..."
!sdkInitialized -> "Initializing DoorstepAI SDK..."
!backgroundLocationGranted -> "DoorstepAI SDK ready (foreground only)"
else -> "DoorstepAI SDK ready"
}
}

Column(
modifier = modifier
.fillMaxSize()
.padding(16.dp)
.verticalScroll(rememberScrollState()),
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = "doorstep.ai DropOff SDK Test App",
fontSize = 24.sp,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(bottom = 16.dp)
)

Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = when {
!permissionsGranted -> MaterialTheme.colorScheme.errorContainer
!sdkInitialized -> MaterialTheme.colorScheme.tertiaryContainer
else -> MaterialTheme.colorScheme.primaryContainer
}
)
) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = statusText,
fontSize = 14.sp,
fontWeight = FontWeight.Medium
)

if (!permissionsGranted) {
Spacer(modifier = Modifier.height(8.dp))
Text(
text = PERMISSION_RATIONALE,
fontSize = 12.sp,
color = MaterialTheme.colorScheme.onErrorContainer
)
}

// Stage 2 — only offered once foreground location is held, and only as its
// own request. Android routes it to the "Allow all the time" Settings screen.
if (permissionsGranted && !backgroundLocationGranted) {
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Foreground location is granted. Background location is a " +
"separate request — without it, collection stops when the app is " +
"backgrounded and route geofencing does not work.",
fontSize = 12.sp
)
Button(onClick = { context.requestBackgroundLocation() }) {
Text("Allow location all the time")
}
}
}
}

Spacer(modifier = Modifier.height(16.dp))

if (permissionsGranted && sdkInitialized) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.secondaryContainer
)
) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = "Delivery ID",
fontSize = 18.sp,
fontWeight = FontWeight.Bold,
modifier = Modifier.padding(bottom = 8.dp)
)

OutlinedTextField(
value = currentDeliveryId,
onValueChange = { currentDeliveryId = it },
label = { Text("Enter Delivery ID") },
modifier = Modifier.fillMaxWidth(),
singleLine = true
)
}
}

Spacer(modifier = Modifier.height(16.dp))

if (isDeliveryActive) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.tertiaryContainer
)
) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = "Active Delivery",
fontSize = 16.sp,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onTertiaryContainer
)
Text(
text = "Type: $activeDeliveryType",
fontSize = 14.sp,
color = MaterialTheme.colorScheme.onTertiaryContainer
)
Text(
text = "ID: $currentDeliveryId",
fontSize = 14.sp,
color = MaterialTheme.colorScheme.onTertiaryContainer
)

Spacer(modifier = Modifier.height(8.dp))

Button(
onClick = {
try {
DoorstepAI.stopDelivery(currentDeliveryId)
statusText = "Delivery stopped for: $currentDeliveryId"
isDeliveryActive = false
activeDeliveryType = ""
} catch (e: Exception) {
statusText = "Error stopping delivery: ${e.message}"
}
},
modifier = Modifier.fillMaxWidth(),
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.error
)
) {
Text("Stop Active Delivery")
}
}
}

Spacer(modifier = Modifier.height(16.dp))
}

if (!isDeliveryActive) {
Text(
text = "Start New Delivery",
fontSize = 18.sp,
fontWeight = FontWeight.Bold,
modifier = Modifier.align(Alignment.Start)
)

Spacer(modifier = Modifier.height(8.dp))

OutlinedTextField(
value = placeId,
onValueChange = { placeId = it },
label = { Text("Place ID") },
modifier = Modifier.fillMaxWidth(),
singleLine = true
)

Button(
onClick = {
if (currentDeliveryId.isNotEmpty() && placeId.isNotEmpty()) {
try {
DoorstepAI.startDeliveryByPlaceID(placeId, currentDeliveryId) { result ->
result.fold(
onSuccess = { message ->
statusText = message
isDeliveryActive = true
activeDeliveryType = "Place ID"
},
onFailure = { error ->
statusText = "Failed to start delivery: ${error.message}"
}
)
}
} catch (e: Exception) {
statusText = "Error starting delivery: ${e.message}"
}
} else {
statusText = "Please enter both Delivery ID and Place ID"
}
},
modifier = Modifier.fillMaxWidth(),
enabled = currentDeliveryId.isNotEmpty() && placeId.isNotEmpty()
) {
Text("Start Delivery by Place ID")
}

Button(
onClick = {
if (currentDeliveryId.isNotEmpty()) {
// markDropoff is a suspend function; dispatch it on the Compose coroutine scope
scope.launch {
try {
DoorstepAI.markDropoff(currentDeliveryId, DropoffType.POD)
statusText = "Marked dropoff: $currentDeliveryId"
} catch (e: Exception) {
statusText = "Error marking dropoff: ${e.message}"
}
}
} else {
statusText = "Please enter a Delivery ID"
}
},
modifier = Modifier.fillMaxWidth(),
enabled = currentDeliveryId.isNotEmpty()
) {
Text("Mark Dropoff (POD)")
}

Spacer(modifier = Modifier.height(8.dp))

Text(
text = "Address Components",
fontSize = 16.sp,
fontWeight = FontWeight.Medium,
modifier = Modifier.align(Alignment.Start)
)

OutlinedTextField(
value = streetNumber,
onValueChange = { streetNumber = it },
label = { Text("Street Number") },
modifier = Modifier.fillMaxWidth(),
singleLine = true
)

OutlinedTextField(
value = route,
onValueChange = { route = it },
label = { Text("Route") },
modifier = Modifier.fillMaxWidth(),
singleLine = true
)

OutlinedTextField(
value = subPremise,
onValueChange = { subPremise = it },
label = { Text("Sub Premise (Apt/Suite)") },
modifier = Modifier.fillMaxWidth(),
singleLine = true
)

OutlinedTextField(
value = locality,
onValueChange = { locality = it },
label = { Text("Locality (City)") },
modifier = Modifier.fillMaxWidth(),
singleLine = true
)

OutlinedTextField(
value = administrativeArea,
onValueChange = { administrativeArea = it },
label = { Text("Administrative Area (State)") },
modifier = Modifier.fillMaxWidth(),
singleLine = true
)

OutlinedTextField(
value = postalCode,
onValueChange = { postalCode = it },
label = { Text("Postal Code") },
modifier = Modifier.fillMaxWidth(),
singleLine = true
)

Button(
onClick = {
if (currentDeliveryId.isNotEmpty() && streetNumber.isNotEmpty() && route.isNotEmpty()) {
try {
val address = AddressType(
streetNumber = streetNumber,
route = route,
subPremise = subPremise,
locality = locality,
administrativeAreaLevel1 = administrativeArea,
postalCode = postalCode
)
DoorstepAI.startDeliveryByAddressType(address, currentDeliveryId) { result ->
result.fold(
onSuccess = { message ->
statusText = message
isDeliveryActive = true
activeDeliveryType = "Address"
},
onFailure = { error ->
statusText = "Failed to start delivery: ${error.message}"
}
)
}
} catch (e: Exception) {
statusText = "Error starting delivery: ${e.message}"
}
} else {
statusText = "Please enter Delivery ID, Street Number, and Route"
}
},
modifier = Modifier.fillMaxWidth(),
enabled = currentDeliveryId.isNotEmpty() && streetNumber.isNotEmpty() && route.isNotEmpty()
) {
Text("Start Delivery by Address")
}
}

Spacer(modifier = Modifier.height(16.dp))

Text(
text = "Events",
fontSize = 18.sp,
fontWeight = FontWeight.Bold,
modifier = Modifier.align(Alignment.Start)
)

OutlinedTextField(
value = eventName,
onValueChange = { eventName = it },
label = { Text("Event Name") },
modifier = Modifier.fillMaxWidth(),
singleLine = true
)

// newEvent records a custom event on a delivery; pass an optional timestamp to backdate it (see Using the SDK).
Button(
onClick = {
if (currentDeliveryId.isNotEmpty() && eventName.isNotEmpty()) {
try {
DoorstepAI.newEvent(eventName, currentDeliveryId) { result ->
result.fold(
onSuccess = { message ->
statusText = "Event created: $message"
},
onFailure = { error ->
statusText = "Failed to create event: ${error.message}"
}
)
}
} catch (e: Exception) {
statusText = "Error creating event: ${e.message}"
}
} else {
statusText = "Please enter both Delivery ID and Event Name"
}
},
modifier = Modifier.fillMaxWidth(),
enabled = currentDeliveryId.isNotEmpty() && eventName.isNotEmpty()
) {
Text("Create Event")
}
}

Spacer(modifier = Modifier.height(32.dp))
}
}

Troubleshooting

1. SDK Initialization Failures

Problem: SDK fails to initialize with callback errors.

Solution: Use context.applicationContext (not an Activity context) when calling DoorstepAI.init, and check the onFailure branch for the underlying error before setting the API key. Most failures trace back to an invalid/placeholder API key or a missing manifest permission, so verify both first.

2. Runtime Permission Issues

Problem: Location or physical-activity permissions are denied, or background tracking stops as soon as the app leaves the foreground.

Solution: Call DoorstepAI.checkPermissions(activity) and read the per-bucket state instead of guessing — you get one DoorstepPermissionStatus for every bucket you name, GRANTED ones included, and it distinguishes WHEN_IN_USE_ONLY (foreground held, background not — background collection and route geofencing do not work) from DENIED, PERMANENTLY_DENIED and NOT_DECLARED (your manifest is missing the <uses-permission> entry, which is a build fix, not a user refusal). DoorstepAI.requestPermissions(...) then skips whatever is already granted and reports it back in PermissionRequestResult.alreadyGranted. Request background location with its own DoorstepAI.requestBackgroundLocationPermission(activity) call after foreground location is granted; a bundled ask is auto-denied on Android 11+. Forward every result with DoorstepAI.notePermissionRequestResult(...) from onRequestPermissionsResult, or the SDK can only ever report the re-askable DENIED. Full state table and fixes: Permissions → Troubleshooting.

3. Network Connectivity Issues

Problem: SDK fails to communicate with servers.

Solution: Check ConnectivityManager.getNetworkCapabilities() for an active network before assuming an SDK bug. Most "SDK can't reach servers" reports are device connectivity issues (airplane mode, captive Wi-Fi portals, VPN misconfiguration), not SDK problems.

Testing Checklist

Before releasing your Android integration:

  • SDK initializes successfully in Activity
  • API key is valid and securely stored
  • All required permissions are declared in AndroidManifest.xml
  • Runtime permissions are requested in two stages — foreground location first, background location in its own follow-up request
  • DoorstepAI.notePermissionRequestResult(...) is forwarded from onRequestPermissionsResult
  • checkPermissions is re-read after every answer, and WHEN_IN_USE_ONLY is not treated as granted
  • Notification permission is granted (Android 13+)
  • Error handling is implemented for all SDK methods
  • Delivery IDs are unique and meaningful