Skip to main content

Android Integration Guide


Requirements​

ItemRequirement
Min SDKAPI 26 (Android 8.0)
Target SDKAPI 36 (Android 16)
Kotlin1.8+
Dependency injectionHilt 2.54+
AsyncKotlin Coroutines

Dependencies​

Add to your module's build.gradle:

plugins {
id 'com.android.application'
id 'org.jetbrains.kotlin.android'
id 'com.google.dagger.hilt.android'
id 'kotlin-kapt'
id 'org.jetbrains.kotlin.plugin.compose'
}

dependencies {
implementation project(':sdk')
implementation project(':sdk-diagnostic') // required for diagnostic tests

// Hilt
implementation "com.google.dagger:hilt-android:2.54"
kapt "com.google.dagger:hilt-android-compiler:2.54"
implementation 'androidx.hilt:hilt-work:1.3.0'
kapt 'androidx.hilt:hilt-compiler:1.3.0' // required for @HiltWorker
implementation 'androidx.hilt:hilt-navigation-compose:1.3.0'

// Compose + Navigation
implementation "androidx.activity:activity-compose:$compose_version"
implementation "androidx.navigation:navigation-compose:$nav_version"
implementation 'androidx.compose.material3:material3:1.3.2'

// Coroutines
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.11.0'

// WorkManager (required by SDK internals)
// Already pulled transitively — no explicit entry needed unless you target WorkManager directly
}

Both com.google.dagger:hilt-android-compiler and androidx.hilt:hilt-compiler are required. The former processes @HiltAndroidApp / @HiltViewModel; the latter processes @HiltWorker used by the SDK's background workers.


Manifest Configuration​

Permission ownership​

PermissionDeclared inReason
FOREGROUND_SERVICESDK moduleOwned by the SDK; merged automatically into host app
FOREGROUND_SERVICE_SPECIAL_USESDK moduleSame — DropDetectionService declares foregroundServiceType="specialUse"
FOREGROUND_SERVICE_LOCATIONSDK moduleSame — SystemForegroundService (WorkManager) uses foregroundServiceType="location|dataSync"
FOREGROUND_SERVICE_DATA_SYNCSDK moduleSame
ACCESS_FINE_LOCATIONHost appRuntime permission — must be requested by the app; Play Store reviews it per-app
ACCESS_COARSE_LOCATIONHost appSame
ACCESS_BACKGROUND_LOCATIONHost appSensitive runtime permission — cannot be bundled inside an SDK
POST_NOTIFICATIONSHost appRuntime permission — user-facing, app must request it
CAMERAHost appRuntime permission

You do not need to redeclare FOREGROUND_SERVICE_* in your app manifest. Android's manifest merger pulls them in from the SDK automatically.

Google Play Store requirements​

Google Play requires additional disclosures for apps that use sensitive permissions and foreground services. Failure to provide these before submitting will result in a policy rejection or removal from the store.

Foreground service declaration (specialUse)​

The SDK runs DropDetectionService as a foregroundServiceType="specialUse" service. Google Play requires a written justification for specialUse submitted via the Play Console:

  1. Open Play Console → App content → Foreground service permissions.
  2. Select Special use as the foreground service type.
  3. Provide a description of exactly what the service does and why a non-foreground approach cannot achieve the same result. Example description:

"The app uses a foreground service to continuously monitor the device's accelerometer sensor for drop and impact events. Foreground execution is required to ensure the sensor stream is not interrupted by the OS when the app is in the background. This data is used to detect accidental damage to the device for warranty claim purposes."

  1. Upload a screen-recording or video that demonstrates the foreground service notification visible to the user while the feature is in use. The video must show the persistent notification icon in the status bar.

Background location​

ACCESS_BACKGROUND_LOCATION triggers a mandatory Permission Declaration Form in the Play Console:

  1. Open Play Console → App content → Sensitive app permissions → Location permissions.
  2. Explain the core feature that requires background location. Example:

"Background location is used to log the device's location at the time a drop or impact event is detected. This enables warranty claims to include geolocation context. Location is only recorded when a sensor event is detected — not continuously."

  1. Provide a demo video showing the location feature working in the background and the associated UI that informs the user.
  2. Ensure the privacy policy linked in the Play Console covers background location data collection.

POST_NOTIFICATIONS (Android 13+)​

No Play Console declaration is required, but you must:

  • Request the permission at runtime using ActivityResultContracts.RequestPermission().
  • Display a rationale to the user before showing the system dialog. The rationale must clearly connect the permission to the free bonus coverage benefit so the user understands why it is needed.

Recommended rationale (shown before the system dialog)

"To get your free coverage, we need notifications turned on so we can monitor drop detection for insurance purposes. This also helps us monitor usage and support future discounts we may be able to extend."

Consequence messaging (shown alongside or below the rationale)

"If you don't provide permissions you won't get free bonus coverage!"

After denial — redirect to Settings

If the user denies the permission and later tries to activate a feature that requires it, show:

"To activate your free bonus coverage, please enable permissions in your device settings. This permission is needed to complete the activation."

Then open the app's system settings page so the user can grant the permission manually:

val intent = Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
data = Uri.fromParts("package", packageName, null)
}
startActivity(intent)

Camera​

No additional Play Console declaration is required for camera use in this context (photo capture for receipts and glass tests). Ensure your Privacy Policy mentions that photos are captured solely for warranty registration purposes and are not stored beyond the upload.

16 KB memory page support​

Both sdk and sdk-diagnostic are 16 KB memory-page compatible. All native libraries shipped with the SDK are compiled with 16 KB alignment support as required by Android for devices running Android 15 (API 35) and later, and as required by Google Play for all new apps and updates targeting API 36 (Android 16) or higher.

To satisfy the 16 KB requirement in your host app, add the following to your app module's build.gradle:

android {
packagingOptions {
jniLibs {
// Required for 16 KB page alignment — packages .so files uncompressed
useLegacyPackaging = false
}
}
}

Without this setting the .so files are compressed inside the APK/AAB and cannot be mapped at 16 KB-aligned boundaries, causing the "ELF alignment check failed" error at upload time.

Host app manifest​

<manifest ...>

<!-- Runtime permissions the host app must declare and request -->
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />

<!-- Optional: declare camera as not required if you want to ship on devices without one -->
<uses-feature android:name="android.hardware.camera" android:required="false" />

<application
android:name=".YourApplication"
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:enableOnBackInvokedCallback="true"
android:fullBackupContent="@xml/backup_rules"
...>

<!-- Disable Sentry auto-init (SDK initialises it manually) -->
<meta-data
android:name="io.sentry.auto-init"
android:value="false" />

<!-- FileProvider — used by the SDK to share image URIs with the camera -->
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.provider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>

<!-- Disable WorkManager auto-init so the SDK can supply its own WorkerFactory -->
<provider
android:name="androidx.startup.InitializationProvider"
android:authorities="${applicationId}.androidx-startup"
android:exported="false"
tools:node="merge">
<meta-data
android:name="androidx.work.WorkManagerInitializer"
android:value="androidx.startup"
tools:node="remove" />
</provider>

</application>
</manifest>

Backup rules​

Why these rules must live in the host app: Android backup rules (fullBackupContent / dataExtractionRules) are application-level. Library modules cannot inject their own — there is no manifest-merger equivalent for XML rule files. The host app must own them.

The SDK uses several SharedPreferences files. The table below shows which are included in backup and which are excluded.

FileBacked up?Reason
com.tmk.warrantylife.xmlYesUser preferences, registration state, plan settings — should be preserved across reinstalls and device transfers
com.warrantylife.sdk_pref.xmlYesContains device ID used for device-change detection. See backup-restore behaviour below
drop_history.xmlYesDrop event log — preserved so history is not lost on device transfer
sensor_readings.xmlNoRaw accelerometer/gyroscope samples — large, transient, and device-specific; no value after restore

Backup-restore behaviour for com.warrantylife.sdk_pref.xml​

This file stores the device's unique hardware identifier alongside the auth token. Backing it up enables the SDK to automatically detect when the app runs on a new device.

How device-change detection works

On every build() call, the SDK checks whether a device ID is already stored:

  • Empty (fresh install, no backup): The SDK writes the current device's hardware ID and starts normally.
  • Not empty (backup restored an ID from the previous device): The SDK does not overwrite it. The stored ID (old device) does not match the current hardware ID, so hasUserChangedDevice() returns true and the device-changed callback fires — your app can then start the device transfer flow.

Auth token after restore

The restored auth token is tied to the previous device's session and will be rejected by the server with a 401. The device transfer flow handles re-authentication, so this is expected behaviour. No special handling is required in the host app beyond implementing the device-changed callback.

Create res/xml/backup_rules.xml (used on API 30 and below):

<?xml version="1.0" encoding="utf-8"?>
<full-backup-content>
<include domain="sharedpref" path="."/>
<exclude domain="sharedpref" path="sensor_readings.xml"/>
</full-backup-content>

Create res/xml/data_extraction_rules.xml (used on API 31+):

<?xml version="1.0" encoding="utf-8"?>
<data-extraction-rules>
<cloud-backup>
<include domain="sharedpref" path="."/>
<exclude domain="sharedpref" path="sensor_readings.xml"/>
</cloud-backup>
<device-transfer>
<include domain="sharedpref" path="."/>
<exclude domain="sharedpref" path="sensor_readings.xml"/>
</device-transfer>
</data-extraction-rules>

Create res/xml/file_paths.xml:

<?xml version="1.0" encoding="utf-8"?>
<paths>
<external-cache-path name="external_cache" path="." />
</paths>

Hilt Setup​

Application class​

Call WarrantyLifeSDK.build() eagerly in Application.onCreate(), immediately after super.onCreate(). This ensures the SDK is fully initialised before any Activity is created and before Hilt's singleton graph delivers any SDK type to a ViewModel.

@HiltAndroidApp
class MyApplication : Application() {

override fun onCreate() {
super.onCreate()
WarrantyLifeSDK.build(
context = this,
clientCode = "YOUR_CLIENT_CODE",
apiKey = "YOUR_API_KEY",
sentryClientKey = "YOUR_SENTRY_KEY", // pass "" to skip Sentry
appVersionCode = BuildConfig.VERSION_CODE
)
}
}

build() is idempotent — calling it more than once is safe; subsequent calls return immediately without re-initialising anything.

Hilt module — expose ApiRepository​

The SDK's own Hilt module (AppPreferences, installed in SingletonComponent) already provides AppDataManager, PreferenceManager, and the Room-backed repositories. The host app only needs to expose ApiRepository:

@Module
@InstallIn(SingletonComponent::class)
object DataModules {

@Singleton
@Provides
fun provideApiRepository(): ApiRepository = WarrantyLifeSDK.apiRepository
}

WarrantyLifeSDK.apiRepository is a property backed by the ApiClient that build() created. Because build() runs in Application.onCreate() before Hilt ever resolves a ViewModel dependency, this property is always valid by the time provideApiRepository is first called.

Do not call WarrantyLifeSDK.build() inside provideApiRepository. Doing so makes initialisation lazy — it runs only when the first ViewModel that injects ApiRepository is created, which can be after the app is already visible. This delays lifecycle observer registration and causes trackAppLaunch to be missed on the first foreground.

Do not provide AppDataManager yourself. The SDK registers it in its own Hilt module. Declaring a second @Provides fun provideAppDataManager(...) in the host app will cause a [Dagger/DuplicateBindings] compile error. Inject AppDataManager directly wherever needed — Hilt resolves it from the SDK module automatically.


SDK Initialisation​

WarrantyLifeSDK.build() accepts the following parameters:

ParameterTypeDescription
contextContextApplication context used to resolve Hilt dependencies
clientCodeStringYour WarrantyLife client identifier — provided by WarrantyLife
apiKeyStringDeveloper API key sent as X-WL-Developer-Key on every request — provided by WarrantyLife
sentryClientKeyStringSentry DSN for crash reporting; pass "" to disable
appVersionCodeIntHost app version code sent as X-WL-App-Version on every request

Obtaining your credentials: clientCode and apiKey are issued by WarrantyLife during onboarding. If you do not have them, contact support@warrantylife.com before integrating the SDK.

WarrantyLifeSDK.build() performs the following on first call:

  • Resolves all SDK dependencies from the Hilt SingletonComponent via an EntryPoint
  • Persists apiKey and injects it into the HTTP client as the X-WL-Developer-Key request header
  • Initialises the Retrofit API client with your clientCode and Sentry key
  • Sets up Room databases (tests, warranties, drops, notifications)
  • Initialises WorkManager with the SDK's custom WLWorkerFactory
  • Registers Application.ActivityLifecycleCallbacks to call trackAppLaunch() every time the app enters the foreground and trackAppBackground() every time it leaves

Why ActivityLifecycleCallbacks and not ProcessLifecycleOwner? ProcessLifecycleOwner depends on its own ContentProvider initialiser (ProcessLifecycleOwnerInitializer) running correctly. Conflicts with WorkManager's App Startup integration can silently prevent that initialiser from advancing the lifecycle state, causing onStart to never fire. Using Application.registerActivityLifecycleCallbacks() directly eliminates that dependency — the SDK owns the registration and it cannot be disrupted by library initialisation order.

Important: The lifecycle callbacks are registered after ApiClient is fully constructed, so trackAppLaunch always has a valid API client available when it runs.


Lifecycle Callbacks​

The SDK fires events — device changed, invalid API key, notification permission required — back to your app through callbacks. There are two ways to register them.

Implement WarrantyLifeSDKListener in your Activity and pass it to WarrantyLifeSDK.setListener(). This single call registers all five callbacks and triggers refreshDeviceCategory in one step.

@AndroidEntryPoint
class MainActivity : ComponentActivity(), WarrantyLifeSDKListener {

private var notificationPermissionRequestPending = false
private var navigateToRegistration: (() -> Unit)? = null

private val requestNotificationPermission = registerForActivityResult(
ActivityResultContracts.RequestPermission()
) { isGranted ->
notificationPermissionRequestPending = false
if (isGranted) {
WarrantyLifeSDK.onNotificationPermissionGranted(this)
}
}

// ── WarrantyLifeSDKListener ───────────────────────────────────────────────

override fun onDeviceChanged() {
runOnUiThread { navigateToRegistration?.invoke() }
}

override fun onInvalidAPIKey(errorMessage: String) {
// Show an error to the user or log it — the SDK will not make further
// API calls until build() is called again with a valid key.
Log.e("MainActivity", "WarrantyLife SDK: invalid API key — $errorMessage")
}

override fun onSSRNotificationPermissionRequested() {
requestNotificationPermissionIfNeeded()
}

override fun onSSDNotificationPermissionRequested() {
requestNotificationPermissionIfNeeded()
}

override fun onTradeInNotificationPermissionRequested() {
requestNotificationPermissionIfNeeded()
}

// ── Lifecycle ─────────────────────────────────────────────────────────────

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

// Registers all callbacks and refreshes device category in one call.
WarrantyLifeSDK.setListener(this, this)

setContent {
AppNavigation(onRegisterNavigationReady = { navigateToRegistration = it })
}
}

private fun requestNotificationPermissionIfNeeded() {
if (notificationPermissionRequestPending) return
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
if (ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS)
!= PackageManager.PERMISSION_GRANTED
) {
notificationPermissionRequestPending = true
requestNotificationPermission.launch(Manifest.permission.POST_NOTIFICATIONS)
}
}
}
}

WarrantyLifeSDKListener method reference​

MethodWhen called
onDeviceChanged()SDK detects the app is running on a different device than originally registered
onInvalidAPIKey(errorMessage: String)Server rejects the API key supplied in build()
onSSRNotificationPermissionRequested()SDK needs POST_NOTIFICATIONS to activate Smart Saver (drop-detection) coverage
onSSDNotificationPermissionRequested()SDK needs POST_NOTIFICATIONS to activate Smart Safe Driving coverage
onTradeInNotificationPermissionRequested()SDK needs POST_NOTIFICATIONS to activate Trade-In coverage

setListener parameters​

ParameterTypeDescription
activityActivityCurrent Activity — passed to refreshDeviceCategory internally
listenerWarrantyLifeSDKListenerCallback receiver; typically this when the Activity implements the interface

Option B — Individual setters​

Use these if you cannot implement the interface on your Activity (e.g. you handle callbacks in a separate class).

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

val requestPermission: () -> Unit = { requestNotificationPermissionIfNeeded() }
WarrantyLifeSDK.setSSRNotificationPermissionRequestCallback(requestPermission)
WarrantyLifeSDK.setSSDNotificationPermissionRequestCallback(requestPermission)
WarrantyLifeSDK.setTradeInNotificationPermissionRequestCallback(requestPermission)

WarrantyLifeSDK.setDeviceChangedCallback {
runOnUiThread { navigateToRegistration?.invoke() }
}

WarrantyLifeSDK.setInvalidAPIKeyCallback { errorMessage ->
Log.e("MainActivity", "WarrantyLife SDK: invalid API key — $errorMessage")
}

WarrantyLifeSDK.refreshDeviceCategory(this)
}

Registration Flow​

The full flow is an 8-step nested navigation graph. Each step is a Composable backed by a @HiltViewModel.

voucher_code → required_tests → auth → [receipt] → imei → [date_of_purchase] → [smart_saver] → create_warranty

Steps in [] are conditional and may be skipped depending on plan configuration.

Receipt capture is placed before IMEI entry so that the image file is already written to the cache when registerDevice runs. ReceiptViewModel.saveAndUpload handles the case where itemId is not yet available — it saves the file locally and skips the network upload; the file is then picked up automatically during registerDevice.

Step 1 — Voucher Code (getVoucherByVoucherCode)​

@HiltViewModel
class WarrantyRegistrationViewModel @Inject constructor(
private val apiRepository: ApiRepository,
private val appDataManager: AppDataManager,
) : ViewModel() {

// Pre-fills the field if a voucher code was previously saved
val savedVoucherCode: String = appDataManager.getCurrentVoucherCode()

fun getVoucherByVoucherCode(voucherCode: String) {
viewModelScope.launch {
val response = apiRepository.getVoucherByVoucherCode(
voucherCode = voucherCode,
isPlanTransfer = null
)
// handle ApiSuccessResponse / ApiErrorResponse
}
}
}

Response: Voucher — contains plan details, test requirements, Smart Saver offer flags, registration deadline, and original device details when the plan is already claimed.

Voucher fields reference​

FieldTypeDescription
actionVoucherActionNext action the app must take — see table below
registrationTypeRegistrationTypeForm variant for the registration screen — see §Auth
originalDeviceVoucherDevice?Details of the device the plan was originally registered to. Present when the plan is claimed (isClaimed == true). Used to format the DEVICE_TRANSFERABLE_POPUP message
ownerUser?Account that owns the plan. Email is the source for LOCKED_EMAIL_REGISTRATION
requiresReceiptBoolean?Whether a receipt image must be captured before IMEI entry
verifiedCustomerPurchaseDateString?Pre-filled purchase date if supplied by the retailer. null means the user must enter it
isDeviceTransferableBoolean?Whether the plan can be transferred to a different device
rewardAmountMaxInt?Maximum Smart Saver reward available. > 0 means the Smart Saver screen should be shown

VoucherDevice fields (original device)​

Voucher.originalDevice is populated by the SDK when the plan is already claimed (isClaimed == true). It is a lightweight model containing only the fields needed for UI display:

data class VoucherDevice(
val model: String?, // e.g. "iPhone 13 Pro"
val manufacturerName: String?, // e.g. "Apple"
val serialNumber: String?, // IMEI / serial number
)
// Format DEVICE_TRANSFERABLE_POPUP message with original device details
val device = voucher.originalDevice
val model = device?.model ?: "your device"
val imei = device?.serialNumber ?: "N/A"
val message = action.description.format(model, imei, model)
// → "Looks like this coverage was bought for an iPhone 13 Pro (IMEI: 352800112345678).
// Are you using that iPhone 13 Pro right now? ..."

VoucherAction — routing after voucher validation​

The Voucher.action field tells the app what to do next. Actions with isError = true should be shown as error messages; all others require specific UI handling.

ActionisErrorMeaningRecommended handling
NORMAL_REGISTRATIONfalseDefault — no special conditionsProceed to next step
DEVICE_TRANSFERABLE_POPUPfalseCoverage claimed on a different device; plan can be transferredAsk if user is on the original device — see decision tree below
DEVICE_NON_TRANSFERABLE_POPUPfalseCoverage claimed on a different device; plan cannot be transferredAsk if user is on the original device — see decision tree below
USER_NOT_LOGGED_IN_CLAIMEDfalsePlan already registered; user is not logged inShow info dialog → navigate to Login
DIFFERENT_EMAIL_CLAIMEDfalsePlan claimed under a different accountShow info dialog — user cannot proceed
ACTIVE_WARRANTY_CONFLICTtrueActive warranties on this device under another accountShow error message — contact support
WARRANTY_NOT_TRANSFERABLEtrueSelected plan is non-transferableShow error message
START_WARRANTY_TRANSFERfalsePlan transfer should be initiatedNavigate to plan transfer flow

Device transfer decision tree​

Both DEVICE_TRANSFERABLE_POPUP and DEVICE_NON_TRANSFERABLE_POPUP require you to ask the user whether they are currently using the same device the plan was originally registered to. The user's answer determines which update path the registration flow will follow later (in Step 8 — createWarranty vs updateWarranty). Record it via appDataManager.setDeviceTransferMode(...) before proceeding.

getVoucherByVoucherCode()
│
├─ DEVICE_TRANSFERABLE_POPUP
│ │
│ │ Show dialog: "Are you using the same device
│ │ this plan was originally purchased for?"
│ │ (display originalDevice.model + serialNumber)
│ │
│ ├─ YES (user is on the original device)
│ │ appDataManager.setDeviceTransferMode(DEVICE_TRANSFER_SAME)
│ │ → proceed to Step 2 (required tests)
│ │
│ └─ NO (user is on a different device — they want to transfer)
│ appDataManager.setDeviceTransferMode(DEVICE_TRANSFER_NEW)
│ → proceed to Step 2 (required tests)
│
└─ DEVICE_NON_TRANSFERABLE_POPUP
│
│ Show dialog: "Are you using the same device
│ this plan was originally purchased for?"
│ (display originalDevice.model + serialNumber)
│
├─ YES (user is on the original device)
│ appDataManager.setDeviceTransferMode(DEVICE_TRANSFER_SAME)
│ → proceed to Step 2 (required tests)
│
└─ NO (user is on a different device)
Show error: "Sorry, this warranty can only be used
with the device it was originally purchased for."
→ block — do not proceed

Why does the answer matter? DEVICE_TRANSFER_SAME means the registration flow will call apiRepository.updateWarranty(...) in Step 8 — updating the existing warranty record on the original device. DEVICE_TRANSFER_NEW (only valid for DEVICE_TRANSFERABLE_POPUP) means the flow will call apiRepository.createWarranty(...) in Step 8 — creating a new warranty record on the new device and transferring the plan.

The SDK worker reads this value via preferenceManager.getDeviceTransferMode() to decide which API call to make during upload.

val voucher = response.data ?: return
val action = voucher.action
val registrationType = voucher.registrationType

if (action.isError) {
showError(action.description)
return
}

when (action) {
VoucherAction.NORMAL_REGISTRATION -> navigateToNextStep()

VoucherAction.START_WARRANTY_TRANSFER -> navigateToTransferFlow()

VoucherAction.DEVICE_TRANSFERABLE_POPUP -> {
val device = voucher.originalDevice
showSameDeviceDialog(device?.model, device?.serialNumber) { isSameDevice ->
if (isSameDevice) {
appDataManager.setDeviceTransferMode(DEVICE_TRANSFER_SAME)
} else {
appDataManager.setDeviceTransferMode(DEVICE_TRANSFER_NEW)
}
navigateToNextStep()
}
}

VoucherAction.DEVICE_NON_TRANSFERABLE_POPUP -> {
val device = voucher.originalDevice
showSameDeviceDialog(device?.model, device?.serialNumber) { isSameDevice ->
if (isSameDevice) {
appDataManager.setDeviceTransferMode(DEVICE_TRANSFER_SAME)
navigateToNextStep()
} else {
showError("Sorry, this warranty can only be used with the device it was originally purchased for.")
// do not proceed
}
}
}

VoucherAction.USER_NOT_LOGGED_IN_CLAIMED -> showInfoDialog(action.description) {
onConfirm = { navigateToLogin() }
}

// DIFFERENT_EMAIL_CLAIMED and any unhandled actions
else -> showInfoDialog(action.description)
}

DEVICE_TRANSFER_SAME = 2 and DEVICE_TRANSFER_NEW = 1 are integer constants exported from com.warrantylife.sdk.util. Import them directly rather than using the raw integers.

Step 2 — Required Tests​

Tests required by the plan are read from the local Room database via AppDataManager:

val hasAutoTest = appDataManager.isAutoTestRequired()
val hasSensor = appDataManager.isSensorTestRequired()
val hasFrontGlass = appDataManager.isFrontCameraTestRequired()
val hasBackGlass = appDataManager.isBackCameraTestRequired()
val hasOpenFace = appDataManager.isOpenFaceTestRequired()

Each returns Boolean (suspend). true means DiagnosticRequirement.TEST_REQUIRED.

Step 3 — Auth (login / registerNewUser)​

The screen is skipped automatically if appDataManager.isUserLoggedIn() is already true.

// Login
val response = apiRepository.login(email, password)

// Register new user
val response = apiRepository.registerNewUser(
email, password, firstName, lastName,
phone, streetAddress, city, state, country, zip, company
)

Both return ApiResponse<LoginResponse>. On success, user token and identity are persisted automatically.

RegistrationType — registration form variant​

Voucher.registrationType controls which fields the registration form presents. Read it before rendering the register screen.

TypePassword fieldEmail fieldDescription
NORMAL_REGISTRATIONRequiredUser-enteredStandard account creation
PASSWORDLESS_REGISTRATIONHiddenUser-enteredAccount created without a password
LOCKED_EMAIL_REGISTRATIONRequiredPre-filled, read-onlyEmail is provided by the plan; user sets a password
when (voucher.registrationType) {
RegistrationType.NORMAL_REGISTRATION -> {
// show email + password + profile fields
}
RegistrationType.PASSWORDLESS_REGISTRATION -> {
// show email + profile fields; pass "" for password
}
RegistrationType.LOCKED_EMAIL_REGISTRATION -> {
// pre-fill email from voucher.owner?.email, make it read-only
// show password + profile fields
}
}

Step 4 — Receipt Image (conditional)​

Shown immediately after auth when appDataManager.isReceiptRequired() is true. Capturing the image here ensures it is present in the cache before registerDevice runs.

appDataManager.setReceiptImagePath(filePath) // absolute path to JPEG

If the user skips this screen the file path is left empty; registerDevice will still succeed but the receipt upload step will be a no-op.

Step 5 — IMEI + Register Device (registerDevice)​

appDataManager.setIMEI(imei)

apiRepository.registerDevice(context).collect { state ->
when (state) {
is RegisterDeviceState.Submitted -> navigateToNextScreen()
is RegisterDeviceState.Progress -> showProgress(state.percentage)
is RegisterDeviceState.Success -> { /* upload complete */ }
is RegisterDeviceState.ValidationError -> handleValidationError(state.error)
is RegisterDeviceState.UploadError -> showError(state.message)
}
}

registerDevice is a Flow that streams upload progress. It uploads sensor test data, glass test images, and the receipt image (if captured in step 4) as a multi-step background job.

RegisterDeviceState reference​

StatePayloadWhen it fires
Submitted—Worker has been enqueued and upload is about to begin — use this to navigate away or show a loader
Progresspercentage: IntUpload is in progress; emitted repeatedly as each file is sent
Success—All data uploaded successfully; proceed to the next registration step
ValidationErrorerror: WLSDKErrorA required piece of data is missing before any network call is made
UploadErrormessage: String, code: IntThe server returned an error response to an API call during upload

ValidationError vs UploadError: ValidationError is a local pre-flight check — the SDK detects missing data and fails fast without hitting the network. UploadError means the request reached the server and the server rejected it (e.g. HTTP 4xx/5xx); code contains the HTTP status code.

WLSDKError — ValidationError cases​

Each WLSDKError value tells you exactly what is missing and what the user must do before retrying:

ErrorMeaningRecommended action
MISSING_IMEINo IMEI has been setShow error on the IMEI field; user must re-enter
MISSING_RECEIPT_IMAGEReceipt image path is not setNavigate back to the receipt capture screen
MISSING_SENSOR_TEST_DATASensor test was not completedNavigate to the sensor test screen
MISSING_AUTO_SYSTEM_TEST_DATAAuto system test was not completedNavigate to the auto-system test screen
MISSING_FRONT_GLASS_IMAGEFront glass photo is missingReset front glass test status; navigate to front glass test
MISSING_BACK_GLASS_IMAGEBack glass photo is missingReset back glass test status; navigate to back glass test
MISSING_OPEN_FACE_TEST_IMAGEOpen face photo is missingReset open face test status; navigate to open face test
is RegisterDeviceState.ValidationError -> {
when (state.error) {
WLSDKError.MISSING_IMEI -> {
// show error on IMEI input field
}
WLSDKError.MISSING_RECEIPT_IMAGE -> {
// navigate back to receipt capture screen
}
WLSDKError.MISSING_SENSOR_TEST_DATA -> {
// navigate to sensor test
}
WLSDKError.MISSING_AUTO_SYSTEM_TEST_DATA -> {
// navigate to auto-system test
}
WLSDKError.MISSING_FRONT_GLASS_IMAGE -> {
// navigate to front glass test
}
WLSDKError.MISSING_BACK_GLASS_IMAGE -> {
// navigate to back glass test
}
WLSDKError.MISSING_OPEN_FACE_TEST_IMAGE -> {
// navigate to open face test
}
}
}

Step 6 — Date of Purchase (conditional)​

Shown when appDataManager.isPurchaseDateRequired() is true.

appDataManager.setReceiptDate(date) // "yyyy-MM-dd"

Step 7 — Smart Saver (conditional)​

Shown when appDataManager.hasSsr() || appDataManager.hasSsd() || appDataManager.isTipOffered().

See §10 for full details.

Step 8 — Create / Update Warranty​

// New registration
val response = apiRepository.createWarranty(context, model)

// Plan transfer (device change)
val response = apiRepository.updateWarranty(context, model)

WLWarrantyRequest fields:

FieldTypeDescription
firstName / lastNameStringUser name
companyString?Optional company
streetAddress, city, zipStringAddress fields
stateStringProvince.id from getAppConfig() — not the display name or abbreviation
countryStringCountry.id from getAppConfig() — not the display name or abbreviation
phoneStringContact number
dateOfPurchaseString?"yyyy-MM-dd" format
acceptedMarketingBooleanMarketing consent
isDeviceInsuredBoolean?Whether device is separately insured

On success, coverage state, opt-in flags, and heartbeat schedule are persisted automatically. The drop-detection service is started if all conditions are met (see §11).


Try Before You Buy Registration​

A trial flow lets the user run a diagnostic and activate coverage on a provisional basis before committing.

startTrial() → required tests → notification permission → completeTrialSetup()

Step 1 — Start the trial​

apiRepository.startTrial(retailerSku = "YOUR_RETAILER_SKU")
ParameterTypeRequiredDescription
retailerSkuString?NoRetailer-specific SKU that identifies the product being trialled. Pass null if not applicable.

Call this when the user enters the trial flow. It signals the backend to open a provisional coverage window for the device.

Step 2 — Run required tests​

Run the same diagnostic tests as the standard registration flow (see §13). The test suite is unchanged; the distinction is that results are submitted as part of a trial rather than a full warranty.

Step 3 — Request notification permission (Android)​

The drop-detection service requires the POST_NOTIFICATIONS permission on Android 13+. Request it at this point in the flow, before completing the trial, so the service can start immediately on completeTrialSetup.

// Check and request at the appropriate point in your UI
WarrantyLifeSDK.setSSRNotificationPermissionRequestCallback {
// launch your permission request UI here
}

Step 4 — Complete the trial​

apiRepository.completeTrialSetup()

Call this after the user has finished the required tests and granted (or declined) notification permission. On success the trial period is active and the drop-detection service will start if all conditions are met.

Querying trial state​

val isActive: Boolean = appDataManager.isTrialActive()

Returns true while a trial period is open for the current device.


Trade-In Registration​

The trade-in flow captures a device condition score that is used to calculate a trade-in value.

startTradeInRegistration() → required tests → notification permission → completeTradeInRegistration()

Step 1 — Start trade-in registration​

apiRepository.startTradeInRegistration(retailerSku = "YOUR_RETAILER_SKU")
ParameterTypeRequiredDescription
retailerSkuString?NoRetailer-specific SKU that identifies the product being registered for trade-in. Pass null if not applicable.

Step 2 — Run required tests​

Run the diagnostic tests applicable to trade-in (see §13). The SDK determines which tests are required based on the active plan configuration.

Step 3 — Request notification permission (Android)​

Same as the standard and trial flows — request POST_NOTIFICATIONS before completing registration so the service can start immediately.

Step 4 — Complete trade-in registration​

apiRepository.completeTradeInRegistration()

Querying the trade-in score​

val score: String = appDataManager.getTradeInScore().let { raw ->
if (raw <= 0f) "Pending%" else "${raw.toInt()}%"
}

Returns "Pending%" when the score has not yet been calculated by the backend, otherwise returns the score as a percentage string (e.g. "82%").


Smart Saver Opt-Ins​

setSSR, setSSD, and setTradeIn each accept (context, enabled) and return OptInResult.

when (val result = appDataManager.setSSR(context, enabled)) {
is OptInResult.Success -> { /* saved */ }
is OptInResult.Error -> showError(result.reason.message)
}

Validation (when enabled = true)​

CheckError returned
User not logged inOptInError.NOT_LOGGED_IN
No active coverageOptInError.NO_ACTIVE_COVERAGE
Notification permission deniedOptInError.NOTIFICATION_PERMISSION_DENIED

Exception: If a registration or plan transfer is in progress (isRegistrationInProgress() or isDeviceTransferInProgress()), all three checks are skipped and the opt-in is saved immediately.

When enabled = true and all checks pass, the drop-detection service is started automatically if it is not already running.

Reading current values​

val ssrOn = appDataManager.getSSR()
val ssdOn = appDataManager.getSSD()
val tipOn = appDataManager.getTradeIn()

Checking offer availability​

val hasSsr = appDataManager.hasSsr()
val hasSsd = appDataManager.hasSsd()
val hasTip = appDataManager.isTipOffered()
val maxReward = appDataManager.getRewardAmountMax()
val reward = appDataManager.getRewardAmount()
val currency = appDataManager.getPlanCurrency()
val tradeScore = appDataManager.getTradeInScore() // Float — current device trade-in score
MethodReturnsDescription
hasSsr()BooleanWhether the SSR (Safe Driving Reward) offer is available for the plan
hasSsd()BooleanWhether the SSD (Safe Driving Discount) offer is available for the plan
isTipOffered()BooleanWhether the Trade-In Protection offer is available for the plan
getRewardAmountMax()IntMaximum reward amount available (show Smart Saver screen when > 0)
getRewardAmount()IntCurrent earned reward amount
getPlanCurrency()StringCurrency code for reward amounts (e.g. "$")
getTradeInScore()FloatCurrent device trade-in score. Updated automatically on every trackAppLaunch. Returns 0f if no score has been received yet

getTradeInScore() is refreshed from the server on every foreground launch via getItemByDeviceID(). Display it alongside the Trade-In Protection offer to show users their device's current estimated trade-in value. A score of 0f indicates the value has not yet been retrieved — show a loading or unavailable state rather than displaying zero.


Drop-Detection Service​

The DropDetectionService is a foreground service that monitors for drops using the accelerometer. It is started by the SDK automatically — you do not call it directly.

When the service starts​

The SDK evaluates the following conditions and starts the service if all are met:

  1. User is logged in
  2. Has active coverage
  3. At least one of SSR, SSD, or Trade-In is enabled
  4. Notification permission is granted (or device is pre-API 33)

This evaluation runs on every trackAppLaunch, setSSR/setSSD/setTradeIn success, and createWarranty/updateWarranty success.

Heartbeat, scanning, and background location​

While the service is running, the SDK sends a heartbeat API call every 60 minutes (the exact interval is configurable by the server). This heartbeat lets the platform confirm the device is actively running Drop-Detection 24×7.

Alongside each heartbeat, the SDK may also:

  • Scan nearby Bluetooth devices — if the user has granted Bluetooth scan permission and diagnosticRequirements.isBluetoothScanningEnabled > 0 in the getVoucherByVoucherCode response.
  • Scan nearby Wi-Fi networks — if the user has granted Wi-Fi / location permission and diagnosticRequirements.isWifiScanningEnabled > 0 in the getVoucherByVoucherCode response.
  • Collect background location — if the user has granted background location permission.

Required permissions​

PermissionPurpose
android.permission.BLUETOOTH_SCAN (API 31+)Scan nearby Bluetooth devices during heartbeat
android.permission.ACCESS_FINE_LOCATIONRequired on API ≤ 30 for Bluetooth scan; also enables Wi-Fi scan
android.permission.ACCESS_WIFI_STATERead Wi-Fi scan results
android.permission.CHANGE_WIFI_STATETrigger a Wi-Fi scan
android.permission.ACCESS_BACKGROUND_LOCATIONCollect location while the service runs in the background

All of these permissions are optional. The heartbeat will still be sent without them — scanning and location data simply won't be collected.

When to request the permissions​

These permissions must be granted before they can be used by the background service. The recommended time to request them is on the Smart-Saver screen, before the user completes createWarranty / updateWarranty, so the service can start scanning from its very first heartbeat. You may also request them at any other point in the application — for example, on a dedicated permissions on-boarding screen.

ACCESS_BACKGROUND_LOCATION must be requested separately from the other location permissions. On Android 11+ the user must first grant ACCESS_FINE_LOCATION, after which you may request ACCESS_BACKGROUND_LOCATION as a follow-up step.

Handling missing notification permission​

If condition 4 fails, the SDK fires per-opt-in callbacks instead of starting the service:

// Register in Activity.onCreate(), before setContent
WarrantyLifeSDK.setSSRNotificationPermissionRequestCallback {
// show UI or launch system permission dialog
}
WarrantyLifeSDK.setSSDNotificationPermissionRequestCallback {
// same or different handling for SSD
}
WarrantyLifeSDK.setTradeInNotificationPermissionRequestCallback {
// same or different handling for Trade-In
}

Only callbacks for currently-enabled opt-ins are invoked. After the user grants the permission:

WarrantyLifeSDK.onNotificationPermissionGranted(context)
// SDK re-evaluates conditions and starts the service if all are met

Device Change Detection​

On every foreground launch (when logged in), the SDK compares the current device ID against the stored one. If they differ, setDeviceChangedCallback is invoked on a background thread.

WarrantyLifeSDK.setDeviceChangedCallback {
runOnUiThread { navigateToRegistration?.invoke() }
}

Direct the user to the registration flow with isPlanTransfer = true to perform a plan transfer via updateWarranty.


Diagnostic Tests​

Diagnostic tests are managed by the sdk-diagnostic module (declared in §2). The entry point is TestManager, created via its Hilt-injected factory.

@HiltViewModel
class DiagnosticViewModel @Inject constructor(
private val testManagerFactory: TestManager.Factory
) : ViewModel() {

private var testManager: TestManager? = null

fun initialize(activity: Activity) {
testManager = testManagerFactory.create(activity)
}

fun startAutoSystemTest() = testManager?.runAutoSystemTest()
fun startSensorTest() = testManager?.runSensorTest()
fun startFrontGlassTest() = testManager?.runFrontGlassTest()
fun startBackGlassTest() = testManager?.runBackGlassTest()
fun startOpenFaceTest() = testManager?.runOpenFaceTest()

override fun onCleared() {
super.onCleared()
testManager?.stop()
}
}

Collecting results​

FlowTest typeEmitted type
testManager.resultsAuto system testAutoTestResult
testManager.sensorResultsSensor testSensorTestResult
testManager.resultsGlass testsGlassTestResult

Result sealed classes​

sealed class AutoTestResult {
data class Result(val isPassed: Boolean) : AutoTestResult()
data class Failure(val error: WLDiagnosticError) : AutoTestResult()
}

sealed class SensorTestResult {
object Started : SensorTestResult()
data class Result(val isPassed: Boolean) : SensorTestResult()
data class Failure(val error: WLDiagnosticError) : SensorTestResult()
}

sealed class GlassTestResult {
data class Result(
val isPassed: Boolean,
val validationMessage: WLDiagnosticValidationMessage?
) : GlassTestResult()
data class Failure(val error: WLDiagnosticError) : GlassTestResult()
}

WLDiagnosticError distinguishes failure reasons (MISSING_CAMERA_PERMISSION, WIFI_NOT_ENABLED, FOLDABLE_PHONE_NOT_CLOSED, …). Refer to WLDiagnosticError.kt and WLDiagnosticValidationMessage.kt in the sdk-diagnostic module for complete enumerations.


API Reference​

All API functions are suspend and return ApiResponse<T>:

sealed class ApiResponse<T>
class ApiSuccessResponse<T>(val data: T?) : ApiResponse<T>()
class ApiErrorResponse<T>(val errorMessage: String?, val error: String, val errorCode: Int?) : ApiResponse<T>()

Auth​

FunctionParametersReturns
login(email, password)String, StringApiResponse<LoginResponse>
registerNewUser(email, password, firstName, lastName, phone, street, city, state, country, zip, company)String...ApiResponse<LoginResponse>
logout()—Unit
recoverLostPassword(email)StringApiResponse<ResponseBody>
emailRegisteredCheck(email)StringApiResponse<ResponseBody>
getAppConfig()—ApiResponse<AppConfig>

logout is a synchronous, non-suspending function. It clears the auth token, identity, and login state from local storage. No network call is made. Call it when the user explicitly signs out.

apiRepository.logout()
navigateToLogin()

After calling logout(), appDataManager.isUserLoggedIn() returns false. The next trackAppLaunch cycle will call getItemByDeviceIdPublic() instead of the authenticated getItemByDeviceID(). Any in-progress background workers (drop-detection heartbeat, data sync) that require auth will fail gracefully — they check login state before making API calls.

recoverLostPassword triggers a password-reset email to the supplied address. A success response indicates the email was dispatched; the response body carries no structured data. Show a confirmation message regardless of the outcome to avoid disclosing whether the address is registered.

when (val response = apiRepository.recoverLostPassword(email)) {
is ApiSuccessResponse -> showConfirmation("Check your email for a reset link.")
is ApiErrorResponse -> showError(response.errorMessage)
}

emailRegisteredCheck checks whether an email address already has an account before the user reaches the registration screen. Use it to branch between login and sign-up flows without exposing this information through a login error.

when (val response = apiRepository.emailRegisteredCheck(email)) {
is ApiSuccessResponse -> navigateToLogin(email) // address is registered
is ApiErrorResponse -> navigateToRegister(email) // address is not registered
}

getAppConfig returns the list of supported countries and their provinces/states. Call it once on startup (or before showing the registration form) and cache the result locally. The id values from Country and Province are the values that must be passed to WLWarrantyRequest.

when (val response = apiRepository.getAppConfig()) {
is ApiSuccessResponse -> {
val countries = response.data?.countries.orEmpty()
// populate country picker with countries
// when user selects a country, populate state picker with country.provinces
}
is ApiErrorResponse -> showError(response.errorMessage)
}

AppConfig model​

class AppConfig {
val countries: List<Country>? // all supported countries
}

class Country {
val id: String? // pass as `country` in WLWarrantyRequest
val name: String // display name
val abbreviation: String? // e.g. "US", "CA"
val provinces: List<Province>? // states / provinces for this country
val postalCodeRegex: String? // full postal code validation pattern
val partialPostalCodeRegex: String? // partial / in-progress validation pattern
val postalCodeFormatDescription: String?// hint text for the postal code field
val minPhoneNumberDigits: Int? // minimum phone number length
val maxPhoneNumberDigits: Int? // maximum phone number length
}

class Province {
val id: String? // pass as `state` in WLWarrantyRequest
val name: String // display name, e.g. "Ontario"
val abbreviation: String? // short code, e.g. "ON"
val realid: Int? // internal server ID — not required for warranty creation
}

Important: WLWarrantyRequest.country must be set to Country.id and WLWarrantyRequest.state must be set to Province.id — not the display name or abbreviation. Passing a name or abbreviation will cause the server to reject the warranty creation request.

Voucher​

FunctionParametersReturns
getVoucherByVoucherCode(code, isPlanTransfer)String, Boolean?ApiResponse<Voucher>

Device / Item​

FunctionParametersReturns
getItemByDeviceID()—ApiResponse<ItemByDeviceIdResponse>
getItemByDeviceIdPublic()—ApiResponse<DeviceDetail>
registerDevice(context)ContextFlow<RegisterDeviceState>

Device insights​

Fetch the current device's drop rankings, battery rankings, coaching hints, monthly warranty rewards, and Trade-In Protection (TIP) data in one request through ApiRepository. The method uses the SDK's device identity and authenticated session, following the existing device lookup methods. Call it after SDK initialization, sign-in, and device registration.

FunctionParametersReturns
getInsightData()—ApiResponse<InsightData>
// ApiRepository
suspend fun getInsightData(): ApiResponse<InsightData>

In a ViewModel that injects ApiRepository using the Hilt setup above:

viewModelScope.launch {
when (val response = apiRepository.getInsightData()) {
is ApiSuccessResponse -> {
val insights = response.data
if (insights == null) {
showInsightsUnavailable()
return@launch
}

// Use insights.dropRank, insights.batteryRank,
// insights.coaching, insights.rewards, and insights.tip
// to populate the screen.
renderInsights(insights)
}
is ApiErrorResponse -> {
showError(response.errorMessage ?: response.error)
}
}
}

renderInsights, showInsightsUnavailable, and showError represent host app UI handling. A successful response can still have no data; show an unavailable state in that case. Handle failures through the existing ApiErrorResponse fields.

Insight models​

The SDK models map the payload's field names directly. dropRank and batteryRank share the same history type. rewards.current, rewards.projected, and each rewards.history entry share the same monthly reward type. tip.current and each tip.daily entry share the same dated value type.

data class InsightData(
val generatedAt: String,
val timeZone: String,
val dropRank: InsightRankHistory,
val batteryRank: InsightRankHistory,
val coaching: InsightCoaching,
val rewards: InsightRewards,
val tip: InsightTipHistory,
)

data class InsightRankHistory(
val hourly: List<HourlyInsightRank>,
val daily: List<DailyInsightRank>,
val weekly: List<WeeklyInsightRank>,
val monthly: List<MonthlyInsightRank>,
)

data class HourlyInsightRank(
val hour: Int,
val date: String,
val rank: Int,
val isProjected: Boolean,
)

data class DailyInsightRank(
val date: String,
val rank: Int,
val isProjected: Boolean,
)

data class WeeklyInsightRank(
val startDate: String,
val rank: Int,
val isProjected: Boolean,
)

data class MonthlyInsightRank(
val month: String,
val rank: Int,
val isProjected: Boolean,
)

data class InsightCoaching(
val generalHints: List<String>,
val batteryHints: List<String>,
val tipHints: List<String>,
)

data class InsightRewards(
val current: InsightReward?,
val projected: InsightReward?,
val history: List<InsightReward>,
)

data class InsightReward(
val month: String,
val amount: Double,
)

data class InsightTipHistory(
val current: DailyInsightTip,
val daily: List<DailyInsightTip>,
val weekly: List<WeeklyInsightTip>,
val monthly: List<MonthlyInsightTip>,
)

data class DailyInsightTip(
val date: String,
val percentage: Double,
val value: Double,
)

data class WeeklyInsightTip(
val startDate: String,
val percentage: Double,
val value: Double,
)

data class MonthlyInsightTip(
val month: String,
val percentage: Double,
val value: Double,
)

Reading the insight data​

FieldMeaning / display guidance
generatedAtServer snapshot timestamp in ISO 8601 format, for example 2026-09-11T22:17:43Z. Indicates when the data was generated.
timeZoneTime zone for the calendar periods, for example America/Toronto. Use this zone when interpreting history dates and hours. Since the time zone used is based off of the device's timezone, the pertinent data is already given in the device's timezone in order to avoid the need to do any timezone conversion.
dropRank, batteryRankSeparate ranking history data, each with hourly, daily, weekly, and monthly arrays.
rankServer-provided integer rank, range from 1 to 100.
isProjectedWhether that individual rank is for a current time period for which the final result is not yet determined. A projected rank can still change until the end of the time period.
coaching.generalHints, coaching.batteryHints, coaching.tipHintsArrays of translated, display-ready hints grouped by topic appropriate to the user's performance.
rewards.currentReward currently assigned to the warranty, including the UTC month in which it became effective, or null when the device has no active reward-eligible warranty or no current reward.
rewards.projectedNon-persisted estimate for the next UTC month's reward, or null when the estimate cannot be calculated. It can change until the monthly reward is granted.
rewards.historyPersisted monthly rewards for up to the current and previous 11 UTC months, ordered by month. The array can be empty and months are not guaranteed to be consecutive.
rewards.*.monthEffective UTC month in YYYY-MM format.
rewards.*.amountNumeric warranty reward amount. Currency is not included; format it using the warranty or integration currency.
tip.currentCurrent Trade-In Prediction (TIP) data.
tip.daily, tip.weekly, tip.monthlyTIP history. Note that TIP has no hourly series or isProjected field in this payload.
percentagePercentage in percentage units: 82.0 is displayed as 82%, not multiplied by 100.
valueNumeric TIP value. No currency is included in this payload; use the currency agreed for TIP values in your integration when formatting an amount.

Hourly ranks use date (YYYY-MM-DD) plus hour (0–23); daily entries use date; weekly entries use startDate (YYYY-MM-DD); monthly entries use month (YYYY-MM). These date/time values are in the device's timezone. Use the returned weekly start date rather than assuming a particular weekday.

Handle empty history and hint arrays as empty states. Treat rewards.current and rewards.projected as optional, and do not synthesize a reward when either is null. Do not assume a fixed history length, consecutive periods, or that separate series contain matching dates. Use the period fields when aligning chart data, and leave missing periods unavailable rather than inventing zero values. Fetch a new snapshot when the insights screen loads.

Warranty​

FunctionParametersReturns
createWarranty(context, model)Context, WLWarrantyRequestApiResponse<Warranty>
updateWarranty(context, warrantyId, model)Context, String, WLWarrantyRequestApiResponse<Warranty>
getAllWarranties()—ApiResponse<List<Warranty>>

Notifications​

FunctionParametersReturns
updateFCMToken(itemId, token)String, StringApiResponse<UpdateItemResponse>

Update the FCM token whenever Firebase delivers a new one:

WarrantyLifeSDK.updateFCMToken(context, fcmToken)

AppDataManager Reference​

Obtain via WarrantyLifeSDK.getAppDataManager(context) or inject with Hilt.

Auth state​

fun isUserLoggedIn(): Boolean
fun hasActiveCoverage(): Boolean

To log the user out, call apiRepository.logout() — see §14 Auth.

Voucher / registration​

fun getCurrentVoucherCode(): String
fun isReceiptRequired(): Boolean
fun isPurchaseDateRequired(): Boolean
fun isDeviceTransferInProgress(): Boolean
fun getEncodedWarrantyID(): String?

Device​

fun hasUserChangedDevice(context: Context): Boolean
fun setIMEI(imei: String)
fun setReceiptDate(date: String)
fun setReceiptImagePath(path: String?)

Smart Saver opt-ins​

fun setSSR(context: Context, enabled: Boolean): OptInResult
fun setSSD(context: Context, enabled: Boolean): OptInResult
fun setTradeIn(context: Context, enabled: Boolean): OptInResult
fun getSSR(): Boolean
fun getSSD(): Boolean
fun getTradeIn(): Boolean

Offer availability​

fun hasSsr(): Boolean // SSR offer available for the plan
fun hasSsd(): Boolean // SSD offer available for the plan
fun isTipOffered(): Boolean // Trade-In Protection offer available
fun getRewardAmountMax(): Int // Maximum reward amount (show Smart Saver screen when > 0)
fun getRewardAmount(): Int // Current earned reward amount
fun getPlanCurrency(): String // Currency code for reward display (e.g. "$")
fun getTradeInScore(): Float // Current device trade-in score; 0f if not yet received

Device ID testing helpers​

Every warranty/plan is tied to the device ID of the physical handset that was registered. These two functions let you simulate a fresh, unregistered device during testing without wiping real data or using a second physical device.

FunctionSignatureDescription
setMockDeviceId()() → UnitReplaces the stored device ID with a randomly generated mock ID ("Mock_<uuid>"), logs the user out, and clears all session and voucher state. The SDK will no longer find a warranty for this ID, so the app behaves as if it has no coverage
resetDeviceId(context)(Context) → UnitRestores the stored device ID to the real hardware device ID, undoing setMockDeviceId()
// Simulate a device with no warranties (e.g. to test the registration flow)
appDataManager.setMockDeviceId()
// Then re-launch the app (or call trackAppLaunch) so the SDK re-evaluates coverage

// Restore the real device ID when done testing
appDataManager.resetDeviceId(context)
// Then re-launch the app so the SDK picks up the real coverage again

Typical testing workflow​

  1. Call appDataManager.setMockDeviceId() — the stored device ID is replaced with "Mock_<uuid>", and the user is logged out with all session and voucher state cleared.
  2. Re-launch the app (or trigger trackAppLaunch). The SDK calls getItemByDeviceID() with the mock ID, finds no warranty, and the app enters its unregistered state.
  3. Complete the registration / purchase flow you want to test.
  4. When finished, call appDataManager.resetDeviceId(context) to restore the real device ID.
  5. Re-launch the app so the SDK re-loads the real coverage.

Do not ship setMockDeviceId() behind a user-facing toggle. It is intended for debug/QA builds only. Guard calls with BuildConfig.DEBUG or a dedicated QA flag.

Diagnostic requirements​

suspend fun isAutoTestRequired(): Boolean
suspend fun isSensorTestRequired(): Boolean
suspend fun isFrontCameraTestRequired(): Boolean
suspend fun isBackCameraTestRequired(): Boolean
suspend fun isOpenFaceTestRequired(): Boolean

ProGuard / R8​

# SDK models and repositories
-keep class com.warrantylife.sdk.model.** { *; }
-keep class com.warrantylife.sdk.repository.** { *; }
-keep interface com.warrantylife.sdk.api.ApiService { *; }

# Hilt entry points
-keep @dagger.hilt.InstallIn class * { *; }
-keep @dagger.hilt.EntryPoint interface * { *; }

# WorkManager workers (required for WLWorkerFactory reflection)
-keep class com.warrantylife.sdk.worker.** { *; }
-keep class com.warrantylife.sdk.dropdetection.worker.** { *; }

# Third-party
-keep class io.sentry.** { *; }
-keep class retrofit2.** { *; }
-keep class com.google.gson.** { *; }
-keepattributes *Annotation*

Notification Customisation​

The SDK manages seven notifications used by its foreground services and background workers. Call WarrantyLifeSDK.updateNotification() to override the title, description, or deep-link intent for any of them. This must be called after WarrantyLifeSDK.build() — a call made before build() is ignored and an error is logged.

WLNotificationType reference​

TypeIDUsed byDescription
DROP_DETECTION_SERVICE1001DropDetectionServicePersistent foreground notification while drop-detection is running
DROP_DETECTED1002DropDetectionServiceShown when a drop impact is detected
BACKGROUND_LOCATION1003BgLocationWorkerShown while background location is being fetched
DATA_SYNC1004UploadDropDataWorkerShown while drop data is syncing to the server
HEARTBEAT1005HeartbeatWorkerShown during periodic heartbeat work
RE_LOGIN1006SDK auth checkShown when re-authentication is required; tap navigates to login
REWARDS_REACTIVATION1007SDKShown when Smart Saver rewards need to be reactivated

updateNotification signature​

WarrantyLifeSDK.updateNotification(
type: WLNotificationType,
title: String? = null,
description: String? = null,
intentAction: String? = null,
intentData: String? = null
)

All parameters except type are optional — pass only the fields you want to override; unset fields preserve the existing stored value.

ParameterTypeDescription
typeWLNotificationTypeThe notification to update
titleString?Notification title. null keeps the existing value
descriptionString?Notification body text. null keeps the existing value
intentActionString?Intent action string — use your app's component action (e.g. "com.yourapp.ACTION_MAIN") to bring the app to the foreground, or Intent.ACTION_VIEW for a deep link
intentDataString?URI for the intent. Required when intentAction is Intent.ACTION_VIEW. Use your app's deep-link scheme (e.g. "myapp://screen-navigation/login")

Example — updating all notification types from a ViewModel​

fun updateNotificationSettings(context: Context) {
// Persistent foreground notification while drop-detection service is active
WarrantyLifeSDK.updateNotification(
type = WLNotificationType.DROP_DETECTION_SERVICE,
title = context.getString(R.string.app_name),
description = context.getString(R.string.your_smartsaver_rewards_are_active),
intentAction = "com.yourapp.ACTION_MAIN"
)
// Shown when a drop impact is detected
WarrantyLifeSDK.updateNotification(
type = WLNotificationType.DROP_DETECTED,
title = context.getString(R.string.app_name),
description = context.getString(R.string.drop_impact_detected),
intentAction = "com.yourapp.ACTION_MAIN"
)
// Shown while background location is being fetched
WarrantyLifeSDK.updateNotification(
type = WLNotificationType.BACKGROUND_LOCATION,
title = context.getString(R.string.app_name),
description = context.getString(R.string.fetching_location),
intentAction = "com.yourapp.ACTION_MAIN"
)
// Shown while drop data is syncing to the server
WarrantyLifeSDK.updateNotification(
type = WLNotificationType.DATA_SYNC,
title = context.getString(R.string.app_name),
description = context.getString(R.string.drop_data_sync_notification),
intentAction = "com.yourapp.ACTION_MAIN"
)
// RE_LOGIN uses a deep link to navigate directly to the login screen
WarrantyLifeSDK.updateNotification(
type = WLNotificationType.RE_LOGIN,
title = context.getString(R.string.app_name),
description = context.getString(R.string.re_login),
intentAction = Intent.ACTION_VIEW,
intentData = "myapp://screen-navigation/login"
)
// REWARDS_REACTIVATION deep-links to the Smart Saver reactivation screen
WarrantyLifeSDK.updateNotification(
type = WLNotificationType.REWARDS_REACTIVATION,
title = context.getString(R.string.app_name),
description = context.getString(
R.string.click_to_re_activate_your_smartsaver_bonus_of_up_to_s,
appDataManager.getPlanCurrency().getRewardMaxStaticAmount()
),
intentAction = Intent.ACTION_VIEW,
intentData = "myapp://screen-navigation/NOTIFICATION_START_SERVICE/true"
)
}

Call updateNotificationSettings once after WarrantyLifeSDK.build() returns — for example, in MainActivity.onCreate() or in a ViewModel that is initialised at startup.

RE_LOGIN and REWARDS_REACTIVATION use Intent.ACTION_VIEW with a deep-link URI so the notification tap navigates the user directly to the correct screen. All other types use a component action string to bring the app to the foreground without changing the current screen.

HEARTBEAT is omitted from the example above because the SDK manages it internally. You may still customise it via WLNotificationType.HEARTBEAT if your app requires it.


WarrantyLife voucher codes can be delivered via QR code scans or email links. The link encodes the voucher code in its URI; your app reads it and calls getVoucherByVoucherCode to activate the plan.

ScenarioWhat happens
App installedAndroid routes the deep link directly to your Activity
App not installedUser is redirected to the Play Store; after install the voucher code is retrieved via the deferred referral API

Use a custom URI scheme owned by your app. The voucher code is passed as a query parameter:

warrantylife://voucher?code=ABC-1234-XYZ

App Links (recommended for production): You may instead use an https:// App Link (e.g. https://app.yourcompany.com/voucher?code=ABC-1234-XYZ). App Links require domain verification via a /.well-known/assetlinks.json file but open your app directly without a disambiguation dialog. See the Verify Android App Links guide for setup steps.

Manifest Intent Filter​

Add the intent filter to the Activity that should handle the deep link (typically MainActivity). Set launchMode="singleTask" so tapping a deep link while the app is already running re-uses the existing instance and triggers onNewIntent instead of creating a duplicate Activity stack.

<activity
android:name=".MainActivity"
android:launchMode="singleTask"
android:exported="true">

<!-- Custom URI scheme -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:scheme="warrantylife"
android:host="voucher" />
</intent-filter>

<!-- HTTPS App Link (optional — requires domain verification) -->
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:scheme="https"
android:host="app.yourcompany.com"
android:pathPrefix="/voucher" />
</intent-filter>

</activity>

Scenario A — App Is Already Installed​

Android delivers the intent directly to your Activity. Handle it in both onCreate (cold start from a deep link) and onNewIntent (deep link tapped while the app is already running):

class MainActivity : ComponentActivity() {

@Inject lateinit var apiRepository: ApiRepository

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
handleDeepLinkIntent(intent)
}

// Called when launchMode=singleTask and the app is already running
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
handleDeepLinkIntent(intent)
}

private fun handleDeepLinkIntent(intent: Intent?) {
val uri = intent?.data ?: return
if (intent.action != Intent.ACTION_VIEW) return

val voucherCode = uri.getQueryParameter("code") ?: return
activateVoucher(voucherCode)
}

private fun activateVoucher(voucherCode: String) {
lifecycleScope.launch {
when (val response = apiRepository.getVoucherByVoucherCode(
voucherCode = voucherCode,
isPlanTransfer = null,
)) {
is ApiSuccessResponse -> { /* navigate to registration flow */ }
is ApiErrorResponse -> { /* show error to user */ }
}
}
}
}

When the app is not installed, direct deep links cannot open it. Instead:

  1. The QR code or email link redirects to the Play Store with a referral parameter that carries the voucher code.
  2. After the user installs and opens the app for the first time, your code reads the referral string via the Google Play Install Referrer API and extracts the voucher code.

Play Store referral URL format

https://play.google.com/store/apps/details
?id=com.yourapp.package
&referrer=voucher_code%3DABC-1234-XYZ

The referrer value is URL-encoded. The key voucher_code is a convention you define — choose any key name and parse it consistently in your app.

Add the Install Referrer dependency

// app/build.gradle.kts
dependencies {
implementation("com.android.installreferrer:installreferrer:2.2")
}

Read the referrer on first launch

class MainActivity : ComponentActivity() {

@Inject lateinit var apiRepository: ApiRepository

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
checkInstallReferrer()
}

private fun checkInstallReferrer() {
// Skip if already consumed on a previous launch
val prefs = getSharedPreferences("wl_prefs", MODE_PRIVATE)
if (prefs.getBoolean("referrer_consumed", false)) return

val client = InstallReferrerClient.newBuilder(this).build()
client.startConnection(object : InstallReferrerStateListener {

override fun onInstallReferrerSetupFinished(responseCode: Int) {
if (responseCode == InstallReferrerClient.InstallReferrerResponse.OK) {
val referrerString = runCatching {
client.installReferrer.installReferrer
}.getOrNull()

// referrerString looks like: "voucher_code=ABC-1234-XYZ"
val voucherCode = referrerString
?.let { Uri.parse("?$it").getQueryParameter("voucher_code") }

if (!voucherCode.isNullOrEmpty()) {
prefs.edit().putBoolean("referrer_consumed", true).apply()
activateVoucher(voucherCode)
}
}
client.endConnection()
}

override fun onInstallReferrerServiceDisconnected() {
// Connection dropped — referrer unavailable on this launch
}
})
}

private fun activateVoucher(voucherCode: String) {
lifecycleScope.launch {
when (val response = apiRepository.getVoucherByVoucherCode(
voucherCode = voucherCode,
isPlanTransfer = null,
)) {
is ApiSuccessResponse -> { /* navigate to registration flow */ }
is ApiErrorResponse -> { /* show error to user */ }
}
}
}
}

The Install Referrer API returns data only once per install on most devices. The referrer_consumed flag shown above guards against calling getVoucherByVoucherCode on every subsequent launch.