Android Integration Guide
Requirements
| Item | Requirement |
|---|---|
| Min SDK | API 26 (Android 8.0) |
| Target SDK | API 36 (Android 16) |
| Kotlin | 1.8+ |
| Dependency injection | Hilt 2.54+ |
| Async | Kotlin 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-compilerandandroidx.hilt:hilt-compilerare required. The former processes@HiltAndroidApp/@HiltViewModel; the latter processes@HiltWorkerused by the SDK's background workers.
Manifest Configuration
Permission ownership
| Permission | Declared in | Reason |
|---|---|---|
FOREGROUND_SERVICE | SDK module | Owned by the SDK; merged automatically into host app |
FOREGROUND_SERVICE_SPECIAL_USE | SDK module | Same — DropDetectionService declares foregroundServiceType="specialUse" |
FOREGROUND_SERVICE_LOCATION | SDK module | Same — SystemForegroundService (WorkManager) uses foregroundServiceType="location|dataSync" |
FOREGROUND_SERVICE_DATA_SYNC | SDK module | Same |
ACCESS_FINE_LOCATION | Host app | Runtime permission — must be requested by the app; Play Store reviews it per-app |
ACCESS_COARSE_LOCATION | Host app | Same |
ACCESS_BACKGROUND_LOCATION | Host app | Sensitive runtime permission — cannot be bundled inside an SDK |
POST_NOTIFICATIONS | Host app | Runtime permission — user-facing, app must request it |
CAMERA | Host app | Runtime 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:
- Open Play Console → App content → Foreground service permissions.
- Select Special use as the foreground service type.
- 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."
- 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:
- Open Play Console → App content → Sensitive app permissions → Location permissions.
- 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."
- Provide a demo video showing the location feature working in the background and the associated UI that informs the user.
- 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.
| File | Backed up? | Reason |
|---|---|---|
com.tmk.warrantylife.xml | Yes | User preferences, registration state, plan settings — should be preserved across reinstalls and device transfers |
com.warrantylife.sdk_pref.xml | Yes | Contains device ID used for device-change detection. See backup-restore behaviour below |
drop_history.xml | Yes | Drop event log — preserved so history is not lost on device transfer |
sensor_readings.xml | No | Raw 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()returnstrueand 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()insideprovideApiRepository. Doing so makes initialisation lazy — it runs only when the first ViewModel that injectsApiRepositoryis created, which can be after the app is already visible. This delays lifecycle observer registration and causestrackAppLaunchto be missed on the first foreground.
Do not provide
AppDataManageryourself. 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. InjectAppDataManagerdirectly wherever needed — Hilt resolves it from the SDK module automatically.
SDK Initialisation
WarrantyLifeSDK.build() accepts the following parameters:
| Parameter | Type | Description |
|---|---|---|
context | Context | Application context used to resolve Hilt dependencies |
clientCode | String | Your WarrantyLife client identifier — provided by WarrantyLife |
apiKey | String | Developer API key sent as X-WL-Developer-Key on every request — provided by WarrantyLife |
sentryClientKey | String | Sentry DSN for crash reporting; pass "" to disable |
appVersionCode | Int | Host app version code sent as X-WL-App-Version on every request |
Obtaining your credentials:
clientCodeandapiKeyare 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
SingletonComponentvia anEntryPoint - Persists
apiKeyand injects it into the HTTP client as theX-WL-Developer-Keyrequest header - Initialises the Retrofit API client with your
clientCodeand Sentry key - Sets up Room databases (tests, warranties, drops, notifications)
- Initialises WorkManager with the SDK's custom
WLWorkerFactory - Registers
Application.ActivityLifecycleCallbacksto calltrackAppLaunch()every time the app enters the foreground andtrackAppBackground()every time it leaves
Why
ActivityLifecycleCallbacksand notProcessLifecycleOwner?ProcessLifecycleOwnerdepends on its ownContentProviderinitialiser (ProcessLifecycleOwnerInitializer) running correctly. Conflicts with WorkManager's App Startup integration can silently prevent that initialiser from advancing the lifecycle state, causingonStartto never fire. UsingApplication.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
ApiClientis fully constructed, sotrackAppLaunchalways 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.
Option A — WarrantyLifeSDKListener interface (recommended)
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
| Method | When 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
| Parameter | Type | Description |
|---|---|---|
activity | Activity | Current Activity — passed to refreshDeviceCategory internally |
listener | WarrantyLifeSDKListener | Callback 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
| Field | Type | Description |
|---|---|---|
action | VoucherAction | Next action the app must take — see table below |
registrationType | RegistrationType | Form variant for the registration screen — see §Auth |
originalDevice | VoucherDevice? | 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 |
owner | User? | Account that owns the plan. Email is the source for LOCKED_EMAIL_REGISTRATION |
requiresReceipt | Boolean? | Whether a receipt image must be captured before IMEI entry |
verifiedCustomerPurchaseDate | String? | Pre-filled purchase date if supplied by the retailer. null means the user must enter it |
isDeviceTransferable | Boolean? | Whether the plan can be transferred to a different device |
rewardAmountMax | Int? | 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.
| Action | isError | Meaning | Recommended handling |
|---|---|---|---|
NORMAL_REGISTRATION | false | Default — no special conditions | Proceed to next step |
DEVICE_TRANSFERABLE_POPUP | false | Coverage claimed on a different device; plan can be transferred | Ask if user is on the original device — see decision tree below |
DEVICE_NON_TRANSFERABLE_POPUP | false | Coverage claimed on a different device; plan cannot be transferred | Ask if user is on the original device — see decision tree below |
USER_NOT_LOGGED_IN_CLAIMED | false | Plan already registered; user is not logged in | Show info dialog → navigate to Login |
DIFFERENT_EMAIL_CLAIMED | false | Plan claimed under a different account | Show info dialog — user cannot proceed |
ACTIVE_WARRANTY_CONFLICT | true | Active warranties on this device under another account | Show error message — contact support |
WARRANTY_NOT_TRANSFERABLE | true | Selected plan is non-transferable | Show error message |
START_WARRANTY_TRANSFER | false | Plan transfer should be initiated | Navigate 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 = 2andDEVICE_TRANSFER_NEW = 1are integer constants exported fromcom.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.
| Type | Password field | Email field | Description |
|---|---|---|---|
NORMAL_REGISTRATION | Required | User-entered | Standard account creation |
PASSWORDLESS_REGISTRATION | Hidden | User-entered | Account created without a password |
LOCKED_EMAIL_REGISTRATION | Required | Pre-filled, read-only | Email 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
| State | Payload | When it fires |
|---|---|---|
Submitted | — | Worker has been enqueued and upload is about to begin — use this to navigate away or show a loader |
Progress | percentage: Int | Upload is in progress; emitted repeatedly as each file is sent |
Success | — | All data uploaded successfully; proceed to the next registration step |
ValidationError | error: WLSDKError | A required piece of data is missing before any network call is made |
UploadError | message: String, code: Int | The server returned an error response to an API call during upload |
ValidationErrorvsUploadError:ValidationErroris a local pre-flight check — the SDK detects missing data and fails fast without hitting the network.UploadErrormeans the request reached the server and the server rejected it (e.g. HTTP 4xx/5xx);codecontains the HTTP status code.
WLSDKError — ValidationError cases
Each WLSDKError value tells you exactly what is missing and what the user must do before retrying:
| Error | Meaning | Recommended action |
|---|---|---|
MISSING_IMEI | No IMEI has been set | Show error on the IMEI field; user must re-enter |
MISSING_RECEIPT_IMAGE | Receipt image path is not set | Navigate back to the receipt capture screen |
MISSING_SENSOR_TEST_DATA | Sensor test was not completed | Navigate to the sensor test screen |
MISSING_AUTO_SYSTEM_TEST_DATA | Auto system test was not completed | Navigate to the auto-system test screen |
MISSING_FRONT_GLASS_IMAGE | Front glass photo is missing | Reset front glass test status; navigate to front glass test |
MISSING_BACK_GLASS_IMAGE | Back glass photo is missing | Reset back glass test status; navigate to back glass test |
MISSING_OPEN_FACE_TEST_IMAGE | Open face photo is missing | Reset 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:
| Field | Type | Description |
|---|---|---|
firstName / lastName | String | User name |
company | String? | Optional company |
streetAddress, city, zip | String | Address fields |
state | String | Province.id from getAppConfig() — not the display name or abbreviation |
country | String | Country.id from getAppConfig() — not the display name or abbreviation |
phone | String | Contact number |
dateOfPurchase | String? | "yyyy-MM-dd" format |
acceptedMarketing | Boolean | Marketing consent |
isDeviceInsured | Boolean? | 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")
| Parameter | Type | Required | Description |
|---|---|---|---|
retailerSku | String? | No | Retailer-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")
| Parameter | Type | Required | Description |
|---|---|---|---|
retailerSku | String? | No | Retailer-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)
| Check | Error returned |
|---|---|
| User not logged in | OptInError.NOT_LOGGED_IN |
| No active coverage | OptInError.NO_ACTIVE_COVERAGE |
| Notification permission denied | OptInError.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
| Method | Returns | Description |
|---|---|---|
hasSsr() | Boolean | Whether the SSR (Safe Driving Reward) offer is available for the plan |
hasSsd() | Boolean | Whether the SSD (Safe Driving Discount) offer is available for the plan |
isTipOffered() | Boolean | Whether the Trade-In Protection offer is available for the plan |
getRewardAmountMax() | Int | Maximum reward amount available (show Smart Saver screen when > 0) |
getRewardAmount() | Int | Current earned reward amount |
getPlanCurrency() | String | Currency code for reward amounts (e.g. "$") |
getTradeInScore() | Float | Current 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 viagetItemByDeviceID(). Display it alongside the Trade-In Protection offer to show users their device's current estimated trade-in value. A score of0findicates 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:
- User is logged in
- Has active coverage
- At least one of SSR, SSD, or Trade-In is enabled
- 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 > 0in thegetVoucherByVoucherCoderesponse. - Scan nearby Wi-Fi networks — if the user has granted Wi-Fi / location permission and
diagnosticRequirements.isWifiScanningEnabled > 0in thegetVoucherByVoucherCoderesponse. - Collect background location — if the user has granted background location permission.
Required permissions
| Permission | Purpose |
|---|---|
android.permission.BLUETOOTH_SCAN (API 31+) | Scan nearby Bluetooth devices during heartbeat |
android.permission.ACCESS_FINE_LOCATION | Required on API ≤ 30 for Bluetooth scan; also enables Wi-Fi scan |
android.permission.ACCESS_WIFI_STATE | Read Wi-Fi scan results |
android.permission.CHANGE_WIFI_STATE | Trigger a Wi-Fi scan |
android.permission.ACCESS_BACKGROUND_LOCATION | Collect 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_LOCATIONmust be requested separately from the other location permissions. On Android 11+ the user must first grantACCESS_FINE_LOCATION, after which you may requestACCESS_BACKGROUND_LOCATIONas 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
| Flow | Test type | Emitted type |
|---|---|---|
testManager.results | Auto system test | AutoTestResult |
testManager.sensorResults | Sensor test | SensorTestResult |
testManager.results | Glass tests | GlassTestResult |
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
| Function | Parameters | Returns |
|---|---|---|
login(email, password) | String, String | ApiResponse<LoginResponse> |
registerNewUser(email, password, firstName, lastName, phone, street, city, state, country, zip, company) | String... | ApiResponse<LoginResponse> |
logout() | — | Unit |
recoverLostPassword(email) | String | ApiResponse<ResponseBody> |
emailRegisteredCheck(email) | String | ApiResponse<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()returnsfalse. The nexttrackAppLaunchcycle will callgetItemByDeviceIdPublic()instead of the authenticatedgetItemByDeviceID(). 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.countrymust be set toCountry.idandWLWarrantyRequest.statemust be set toProvince.id— not the display name or abbreviation. Passing a name or abbreviation will cause the server to reject the warranty creation request.
Voucher
| Function | Parameters | Returns |
|---|---|---|
getVoucherByVoucherCode(code, isPlanTransfer) | String, Boolean? | ApiResponse<Voucher> |
Device / Item
| Function | Parameters | Returns |
|---|---|---|
getItemByDeviceID() | — | ApiResponse<ItemByDeviceIdResponse> |
getItemByDeviceIdPublic() | — | ApiResponse<DeviceDetail> |
registerDevice(context) | Context | Flow<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.
| Function | Parameters | Returns |
|---|---|---|
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
| Field | Meaning / display guidance |
|---|---|
generatedAt | Server snapshot timestamp in ISO 8601 format, for example 2026-09-11T22:17:43Z. Indicates when the data was generated. |
timeZone | Time 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, batteryRank | Separate ranking history data, each with hourly, daily, weekly, and monthly arrays. |
rank | Server-provided integer rank, range from 1 to 100. |
isProjected | Whether 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.tipHints | Arrays of translated, display-ready hints grouped by topic appropriate to the user's performance. |
rewards.current | Reward 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.projected | Non-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.history | Persisted 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.*.month | Effective UTC month in YYYY-MM format. |
rewards.*.amount | Numeric warranty reward amount. Currency is not included; format it using the warranty or integration currency. |
tip.current | Current Trade-In Prediction (TIP) data. |
tip.daily, tip.weekly, tip.monthly | TIP history. Note that TIP has no hourly series or isProjected field in this payload. |
percentage | Percentage in percentage units: 82.0 is displayed as 82%, not multiplied by 100. |
value | Numeric 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
| Function | Parameters | Returns |
|---|---|---|
createWarranty(context, model) | Context, WLWarrantyRequest | ApiResponse<Warranty> |
updateWarranty(context, warrantyId, model) | Context, String, WLWarrantyRequest | ApiResponse<Warranty> |
getAllWarranties() | — | ApiResponse<List<Warranty>> |
Notifications
| Function | Parameters | Returns |
|---|---|---|
updateFCMToken(itemId, token) | String, String | ApiResponse<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.
| Function | Signature | Description |
|---|---|---|
setMockDeviceId() | () → Unit | Replaces 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) → Unit | Restores 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
- 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. - Re-launch the app (or trigger
trackAppLaunch). The SDK callsgetItemByDeviceID()with the mock ID, finds no warranty, and the app enters its unregistered state. - Complete the registration / purchase flow you want to test.
- When finished, call
appDataManager.resetDeviceId(context)to restore the real device ID. - 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 withBuildConfig.DEBUGor 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
| Type | ID | Used by | Description |
|---|---|---|---|
DROP_DETECTION_SERVICE | 1001 | DropDetectionService | Persistent foreground notification while drop-detection is running |
DROP_DETECTED | 1002 | DropDetectionService | Shown when a drop impact is detected |
BACKGROUND_LOCATION | 1003 | BgLocationWorker | Shown while background location is being fetched |
DATA_SYNC | 1004 | UploadDropDataWorker | Shown while drop data is syncing to the server |
HEARTBEAT | 1005 | HeartbeatWorker | Shown during periodic heartbeat work |
RE_LOGIN | 1006 | SDK auth check | Shown when re-authentication is required; tap navigates to login |
REWARDS_REACTIVATION | 1007 | SDK | Shown 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.
| Parameter | Type | Description |
|---|---|---|
type | WLNotificationType | The notification to update |
title | String? | Notification title. null keeps the existing value |
description | String? | Notification body text. null keeps the existing value |
intentAction | String? | 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 |
intentData | String? | 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_LOGINandREWARDS_REACTIVATIONuseIntent.ACTION_VIEWwith 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.
HEARTBEATis omitted from the example above because the SDK manages it internally. You may still customise it viaWLNotificationType.HEARTBEATif your app requires it.
Deep Link Integration
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.
| Scenario | What happens |
|---|---|
| App installed | Android routes the deep link directly to your Activity |
| App not installed | User is redirected to the Play Store; after install the voucher code is retrieved via the deferred referral API |
18.1 Deep Link URL Structure
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.jsonfile 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 */ }
}
}
}
}
Scenario B — App Is Not Installed (Deferred Deep Link)
When the app is not installed, direct deep links cannot open it. Instead:
- The QR code or email link redirects to the Play Store with a referral parameter that carries the voucher code.
- 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_consumedflag shown above guards against callinggetVoucherByVoucherCodeon every subsequent launch.