Permissions, Privacy and Security

This content was generated with the assistance of AI and should be verified against the official documentation of Android before being relied on in production.

See the section bibliography for the reference material consulted while preparing these pages.

Every capability TrailMate needs beyond its own sandbox — reading the device’s location to record a hike, saving photos to shared storage, sending a notification when a sync finishes — is gated by Android’s permission model. This page covers that model end to end: how permissions are declared and requested (install-time, runtime and special permissions, and the request flow in both Views and Compose), the platform’s user-facing privacy surfaces that sit on top of it (the Privacy Dashboard, per-app data access auditing, the advertising ID and the Play Console’s Data safety form), and the security building blocks TrailMate reaches for once it holds sensitive data or talks to a backend (Keystore-backed encryption, biometric authentication, Credential Manager and passkeys, network and WebView hardening, and the risks of exported components). The baseline is the same one used throughout this section: compileSdk = 37, targetSdk = 36, minSdk = 26. Location itself — the fused location provider API, geofencing and the RecordHikeService foreground service that actually consumes the ACCESS_FINE_LOCATION grant described here — is covered in depth on Camera, Location, Sensors, Media and Connectivity and Background Work: WorkManager, Services and Alarms; this page focuses on the permission and privacy/security mechanics that those pages assume. OAuth-based sign-in and token storage for TrailMate’s backend are covered on Native and Mobile Apps, which this page cross-links wherever credential handling is relevant. Kotlin coroutines and Flow, used throughout the request-flow examples, are covered on Coroutines Basics and Flows and are not re-explained here.

The Permission Model

Android classifies every permission an app can declare into one of three groups, distinguished by how and when the user is asked to grant it.

Category Granted when Examples

Install-time (normal)

At install, automatically, with no user prompt — the system only shows the user the full list in the app’s details page.

INTERNET, ACCESS_NETWORK_STATE, VIBRATE, WAKE_LOCK, RECEIVE_BOOT_COMPLETED, POST_NOTIFICATIONS before API 33 (it became a runtime permission at API 33).

Runtime (dangerous)

At the point the app first calls the sensitive API, via a system dialog the user must accept or deny while the app is running — and can revoke at any later time from Settings.

ACCESS_FINE_LOCATION, ACCESS_COARSE_LOCATION, CAMERA, RECORD_AUDIO, READ_CONTACTS, POST_NOTIFICATIONS (API 33+), READ_MEDIA_IMAGES.

Special

Through a dedicated system settings screen the app deep-links the user to — never through the standard runtime dialog.

SYSTEM_ALERT_WINDOW (draw over other apps), WRITE_SETTINGS, MANAGE_EXTERNAL_STORAGE, PACKAGE_USAGE_STATS, SCHEDULE_EXACT_ALARM on some OEM builds.

All three still start the same way: every permission the app might ever request, of any category, must be declared in the manifest with <uses-permission>. Declaring a normal permission there is the entire request; declaring a dangerous one only makes the app eligible to ask for it at runtime — the manifest entry alone grants nothing.

<!-- app/src/main/AndroidManifest.xml -->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_LOCATION" />

Runtime permissions were introduced because a small set of APIs expose data or hardware a user might reasonably want to withhold even after choosing to install the app — location history, the microphone, the contact list. Splitting the grant from the install step means the user makes that decision with the app already running and (ideally) with visible context for why it is asking, rather than as an all-or-nothing gate the user must clear before they have used the app at all.

Requesting Runtime Permissions

The request flow is the same state machine regardless of UI toolkit: check whether the permission is already granted, and if not, launch the system permission dialog and handle its result. ContextCompat.checkSelfPermission answers the first question in both Views and Compose.

// feature/trails/src/main/kotlin/com/example/trailmate/feature/trails/LocationPermissions.kt
package com.example.trailmate.feature.trails

import android.Manifest
import android.content.Context
import android.content.pm.PackageManager
import androidx.core.content.ContextCompat

fun hasFineLocationPermission(context: Context): Boolean =
    ContextCompat.checkSelfPermission(
        context,
        Manifest.permission.ACCESS_FINE_LOCATION
    ) == PackageManager.PERMISSION_GRANTED

Requesting in Views: ActivityResultContracts

The legacy TrailDetailFragment requests location before starting a hike using the ActivityResultContracts.RequestPermission (single permission) or RequestMultiplePermissions (several at once) contract, registered in onCreate/onViewCreated — the registration itself must happen unconditionally, before the fragment reaches STARTED, since ActivityResultRegistry replays a pending result on process recreation only if the launcher was registered on the same call path every time.

// feature/trails/src/main/kotlin/com/example/trailmate/feature/trails/TrailDetailFragment.kt
package com.example.trailmate.feature.trails

import android.Manifest
import androidx.activity.result.contract.ActivityResultContracts
import androidx.fragment.app.Fragment

class TrailDetailFragment : Fragment() {

    private val requestLocationPermission =
        registerForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
            if (granted) {
                startHikeRecording()
            } else if (shouldShowRequestPermissionRationale(Manifest.permission.ACCESS_FINE_LOCATION)) {
                showRationaleDialog()
            } else {
                showPermissionPermanentlyDeniedMessage()
            }
        }

    private fun onRecordHikeClicked() {
        when {
            hasFineLocationPermission(requireContext()) -> startHikeRecording()
            shouldShowRequestPermissionRationale(Manifest.permission.ACCESS_FINE_LOCATION) ->
                showRationaleDialog { requestLocationPermission.launch(Manifest.permission.ACCESS_FINE_LOCATION) }
            else -> requestLocationPermission.launch(Manifest.permission.ACCESS_FINE_LOCATION)
        }
    }
}

Fragment.shouldShowRequestPermissionRationale() (delegating to the hosting Activity) is how the app tells the first denial apart from a permanent one: it returns true once the user has denied the permission at least once but has not checked "Don’t ask again" (or been denied twice, which the system treats the same way), and false both before the first request ever happened and after the user has permanently opted out. That asymmetry is why the check must run before calling launch(), never inside the result callback for the case where the user just denied it — by the time the callback runs, the flag the check reads has already flipped.

Requesting in Compose: rememberLauncherForActivityResult

Compose wraps the same ActivityResultContracts API in a launcher scoped to composition, with no dependency on the discontinued Accompanist permissions library (superseded once rememberLauncherForActivityResult shipped in androidx.activity:activity-compose).

// feature/trails/src/main/kotlin/com/example/trailmate/feature/trails/PhotoCaptureScreen.kt
package com.example.trailmate.feature.trails

import android.Manifest
import android.content.pm.PackageManager
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.compose.LocalActivity
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Column
import androidx.compose.material3.Button
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext

@Composable
fun PhotoCaptureScreen(onPermissionGranted: () -> Unit) {
    val context = LocalContext.current
    val activity = LocalActivity.current
    var showRationale by remember { mutableStateOf(false) }

    val requestCameraPermission = rememberLauncherForActivityResult(
        contract = ActivityResultContracts.RequestPermission()
    ) { granted ->
        if (granted) {
            onPermissionGranted()
        } else {
            showRationale = activity?.shouldShowRequestPermissionRationale(Manifest.permission.CAMERA) == true
        }
    }

    Column {
        Button(onClick = {
            val alreadyGranted = context.checkSelfPermission(Manifest.permission.CAMERA) ==
                PackageManager.PERMISSION_GRANTED
            if (alreadyGranted) onPermissionGranted() else requestCameraPermission.launch(Manifest.permission.CAMERA)
        }) {
            Text("Add trail photo")
        }
        if (showRationale) {
            Text("Camera access lets you attach a photo to this trail.")
        }
    }
}

The Compose version reads identically to the Views one because it is the same underlying ActivityResultRegistry machinery — rememberLauncherForActivityResult registers the callback during composition and unregisters it when the composable leaves composition, which is the Compose-idiomatic equivalent of registering in onCreate and never re-registering across recompositions. Requesting several permissions together (for example fine and coarse location in one dialog) uses ActivityResultContracts.RequestMultiplePermissions, whose callback receives a Map<String, Boolean> instead of a single Boolean.

Rationale UX

shouldShowRequestPermissionRationale() only tells the app whether to show a rationale; it says nothing about what to show. Google’s guidance is to explain the specific feature that needs the permission, in context, right before the system dialog appears — TrailMate shows "Recording a hike needs your location to draw its route on the map" rather than a generic "This app needs location access" — and never to pre-emptively request a permission on first launch before the user has reached a screen that needs it. A user who denies twice (or checks "Don’t ask again" on the first denial, which several OEM skins offer immediately) can no longer be shown the system dialog at all; requestPermission() returns denied instantly without displaying anything. The only way forward at that point is to detect it (shouldShowRequestPermissionRationale() returns false again after being true) and deep-link the user to the app’s own settings page instead:

// feature/trails/src/main/kotlin/com/example/trailmate/feature/trails/AppSettings.kt
package com.example.trailmate.feature.trails

import android.content.Context
import android.content.Intent
import android.net.Uri
import android.provider.Settings

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

Location Permissions: Approximate, Precise and Background

Location is the runtime permission TrailMate exercises most, and it has the most structure of any permission group.

  • ACCESS_COARSE_LOCATION (approximate location, roughly city-block accuracy) and ACCESS_FINE_LOCATION (precise location, GPS-grade accuracy) can each be requested independently, but since API 31 the system permission dialog presents them together with a toggle, letting the user grant only approximate location even when the app declared and requested ACCESS_FINE_LOCATION. TrailMate must request both permissions in the same RequestMultiplePermissions call and check the result for ACCESS_FINE_LOCATION specifically before relying on GPS-grade accuracy, falling back to a coarser hike track when only ACCESS_COARSE_LOCATION was granted.

  • ACCESS_BACKGROUND_LOCATION is required, on top of one of the two above, for location access while the app is in the background without a visible-started foreground service. It cannot be requested in the same dialog as foreground location — doing so is rejected outright since API 30 — and must be requested afterward, from a screen that explains why background tracking is needed, typically only once the user has already granted foreground location and turned on the feature that needs it. TrailMate’s hike recording does not need it: RecordHikeService (a foreground service of type location, covered on Background Work: WorkManager, Services and Alarms) is started from the visible "Start recording" button, and a location foreground service started while the app is visible keeps receiving updates after the user leaves the app or turns the screen off with only foreground location granted. ACCESS_BACKGROUND_LOCATION is needed only to start such a service from the background, or to receive location with no foreground service at all — in TrailMate, only the optional trailhead geofence alerts on Camera, Location, Sensors, Media and Connectivity. Declaring it also puts the app through Google Play’s background-location review, so TrailMate adds the <uses-permission> entry only together with that feature, and does not declare it in the manifest above.

// feature/trails/src/main/kotlin/com/example/trailmate/feature/trails/LocationPermissions.kt
package com.example.trailmate.feature.trails

import android.Manifest
import androidx.activity.result.contract.ActivityResultContracts

val foregroundLocationPermissions = arrayOf(
    Manifest.permission.ACCESS_FINE_LOCATION,
    Manifest.permission.ACCESS_COARSE_LOCATION
)

// Requested only after foreground location is already granted, and only when the user
// turns on trailhead geofence alerts. Hike recording itself never needs it.
const val BACKGROUND_LOCATION_PERMISSION = Manifest.permission.ACCESS_BACKGROUND_LOCATION

Minimizing Permission Requests

Google’s own guidance — and Play’s app review — treats over-requesting permissions as both a user-trust problem and a review risk: every dangerous or special permission an app declares must map to a feature the user can see, and unused declarations should be removed rather than left "just in case". Practical rules TrailMate follows:

  • Do not declare ACCESS_BACKGROUND_LOCATION for a location foreground service started from the UI; request it only when the user explicitly opts into a feature that genuinely reads location in the background (trailhead geofence alerts), never unconditionally at app startup.

  • Prefer ACCESS_COARSE_LOCATION when approximate accuracy is enough for a feature (for example, showing nearby trail regions on the list screen) and reserve ACCESS_FINE_LOCATION for the hike-recording flow that actually needs GPS-grade points.

  • Use scoped, permission-free alternatives where one exists instead of a broad permission: the system photo picker (ActivityResultContracts.PickVisualMedia) for attaching a trail photo needs no READ_MEDIA_IMAGES grant at all, because the picker itself runs in a separate, trusted process outside TrailMate’s own.

  • Request each permission at the point of use, not in bulk during onboarding, so the rationale is always tied to a concrete action.

Runtime Permission Flow

flowchart TD A["User taps an action that needs a permission"] --> B{"Already granted?"} B -->|"Yes"| C["Proceed with the feature"] B -->|"No"| D{"shouldShowRequestPermissionRationale?"} D -->|"true (denied before)"| E["Show in-context rationale UI"] E --> F["Launch system permission dialog"] D -->|"false (first ask, or permanently denied)"| F F --> G{"User choice"} G -->|"Grant"| C G -->|"Deny once"| H["Feature disabled for this session
rationale shown next time"] G -->|"Deny + Don't ask again"| I["shouldShowRequestPermissionRationale now false
dialog cannot be shown again"] I --> J["Deep-link to app settings
ACTION_APPLICATION_DETAILS_SETTINGS"] J --> K{"User enables it in Settings?"} K -->|"Yes"| C K -->|"No"| H
Figure 1. Requesting a runtime permission, from the first check to a Settings redirect

Privacy Surfaces

Beyond the permission dialog itself, Android exposes several surfaces that let the user (and, in one case, the app itself) see how permissions and identifiers are actually being used.

Privacy Dashboard and the Permission Manager

The system Privacy Dashboard (Settings > Privacy) shows, per permission group, a timeline of which apps accessed location, camera or microphone in the last 24 hours or 7 days, along with the indicator dots that appear in the status bar the moment camera or microphone are in use. It sits alongside the Permission manager, which lists, per permission, every app currently holding it and lets the user revoke it directly — the same revocation an app must handle gracefully by re-checking checkSelfPermission() rather than assuming a grant it received once still holds.

Data Access Auditing

AppOpsManager.setOnOpNotedCallback() (API 30+) lets an app register its own AppOpsManager.OnOpNotedCallback for when its declared permissions are actually exercised — including accesses made on the app’s behalf by libraries inside its process, such as an SDK reading location without the app’s own code ever calling the location API directly. TrailMate uses this in debug builds to log every time a bundled analytics or crash-reporting library touches location, which is otherwise invisible from the app’s own call sites.

// app/src/main/kotlin/com/example/trailmate/PrivacyAuditor.kt
package com.example.trailmate

import android.app.AppOpsManager
import android.app.AsyncNotedAppOp
import android.app.SyncNotedAppOp
import android.content.Context
import android.os.Build
import android.util.Log
import androidx.annotation.RequiresApi

// Called from TrailMateApplication.onCreate() in debug builds only.
@RequiresApi(Build.VERSION_CODES.R)
fun startAuditingDataAccess(context: Context) {
    val callback = object : AppOpsManager.OnOpNotedCallback() {
        // A private-data op noted synchronously by code running in this process.
        override fun onNoted(op: SyncNotedAppOp) = log(op.op, Throwable().stackTraceToString())

        // An op this app noted on its own behalf (e.g. via AppOpsManager.noteOp).
        override fun onSelfNoted(op: SyncNotedAppOp) = log(op.op, Throwable().stackTraceToString())

        // An op noted later, e.g. by a system service delivering a location fix asynchronously.
        override fun onAsyncNoted(op: AsyncNotedAppOp) = log(op.op, op.message)

        private fun log(op: String, detail: String) {
            if (op == AppOpsManager.OPSTR_FINE_LOCATION || op == AppOpsManager.OPSTR_COARSE_LOCATION) {
                Log.i("PrivacyAuditor", "Location accessed ($op):\n$detail")
            }
        }
    }
    context.getSystemService(AppOpsManager::class.java)
        .setOnOpNotedCallback(context.mainExecutor, callback)
}

Advertising ID

The advertising ID (AdvertisingIdClient.getAdvertisingIdInfo) is a resettable, user-controlled identifier for ad personalization and attribution, distinct from any hardware or account identifier. Apps that declare the com.google.android.gms.permission.AD_ID permission (added automatically by many ad and analytics SDKs) must respect the user’s "Delete advertising ID" and "Opt out of Ads Personalization" choices in Settings, and must disclose their advertising ID usage in the Play Console Data safety form below. TrailMate itself does not show ads and removes the AD_ID permission with a manifest <uses-permission android:name="com.google.android.gms.permission.AD_ID" tools:node="remove" /> entry, since it would otherwise be pulled in transitively by an analytics dependency.

Play Console Data Safety Form

Every app published on Google Play must complete a Data safety declaration in the Play Console describing what user data it collects, whether that data is shared with third parties, and why — location, personal identifiers, photos, and so on — which Play then renders on the app’s store listing for users to review before installing. The declaration is a policy and store-listing artifact, not code, but it must stay consistent with what the app actually does: declaring "no location collected" while shipping a RecordHikeService that reads ACCESS_FINE_LOCATION is a policy violation independent of anything the runtime permission system enforces.

SMS and Call Log: Restricted Permissions

READ_SMS, RECEIVE_SMS, READ_CALL_LOG and related permissions are restricted: Play only allows an app to declare them if SMS or call handling is a core, user-facing feature of the app (a default SMS or dialer app, for example), and publishing an app that declares them for an unrelated purpose — such as reading an SMS-based verification code — is rejected in review. TrailMate has no legitimate use for either permission and declares neither; an app that only needs to read a one-time OTP code should use the SMS Retriever API or the Credential Manager sign-in flow instead, both of which read the SMS content without ever holding the broad READ_SMS grant.

Security Building Blocks

Android Keystore and Tink

The Android Keystore system stores cryptographic key material in hardware-backed (or, as a fallback, software) isolation that application code can use but never directly read — a key generated with KeyGenParameterSpec never exists as raw bytes inside the app’s own process, only inside the secure element or TEE, which is what makes it meaningfully harder to exfiltrate than a key stored in SharedPreferences or a file. TrailMate uses it to encrypt the offline cache of a signed-in user’s auth refresh token before writing it to DataStore.

// core/data/src/main/kotlin/com/example/trailmate/core/data/SecureTokenStore.kt
package com.example.trailmate.core.data

import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties
import java.security.KeyStore
import javax.crypto.Cipher
import javax.crypto.KeyGenerator
import javax.crypto.spec.GCMParameterSpec

private const val ANDROID_KEYSTORE = "AndroidKeyStore"
private const val KEY_ALIAS = "trailmate_refresh_token_key"

class SecureTokenStore {

    private val keyStore = KeyStore.getInstance(ANDROID_KEYSTORE).apply { load(null) }

    private fun getOrCreateKey() =
        (keyStore.getKey(KEY_ALIAS, null) as? javax.crypto.SecretKey) ?: run {
            val keyGenerator = KeyGenerator.getInstance(
                KeyProperties.KEY_ALGORITHM_AES,
                ANDROID_KEYSTORE
            )
            keyGenerator.init(
                KeyGenParameterSpec.Builder(
                    KEY_ALIAS,
                    KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT
                )
                    .setBlockModes(KeyProperties.BLOCK_MODE_GCM)
                    .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
                    .build()
            )
            keyGenerator.generateKey()
        }

    fun encrypt(plaintext: ByteArray): Pair<ByteArray, ByteArray> {
        val cipher = Cipher.getInstance("AES/GCM/NoPadding")
        cipher.init(Cipher.ENCRYPT_MODE, getOrCreateKey())
        return cipher.doFinal(plaintext) to cipher.iv
    }

    fun decrypt(ciphertext: ByteArray, iv: ByteArray): ByteArray {
        val cipher = Cipher.getInstance("AES/GCM/NoPadding")
        cipher.init(Cipher.DECRYPT_MODE, getOrCreateKey(), GCMParameterSpec(128, iv))
        return cipher.doFinal(ciphertext)
    }
}

Hand-rolling the javax.crypto calls above correctly (choosing GCM over ECB, sizing the IV, handling padding) is easy to get subtly wrong. Google’s Tink library wraps the Keystore-backed key in a higher-level, misuse-resistant API — AndroidKeysetManager plus an Aead (authenticated encryption with associated data) primitive — and is the recommended default for new code instead of calling Cipher directly:

// core/data/src/main/kotlin/com/example/trailmate/core/data/TinkTokenStore.kt
package com.example.trailmate.core.data

import android.content.Context
import com.google.crypto.tink.Aead
import com.google.crypto.tink.aead.AeadConfig
import com.google.crypto.tink.integration.android.AndroidKeysetManager

class TinkTokenStore(context: Context) {

    private val aead: Aead

    init {
        AeadConfig.register()
        val keysetManager = AndroidKeysetManager.Builder()
            .withSharedPref(context, "trailmate_keyset", "trailmate_keyset_prefs")
            .withKeyTemplate(com.google.crypto.tink.aead.AesGcmKeyManager.aes256GcmTemplate())
            .withMasterKeyUri("android-keystore://trailmate_master_key")
            .build()
        aead = keysetManager.keysetHandle.getPrimitive(Aead::class.java)
    }

    fun encrypt(plaintext: ByteArray, associatedData: ByteArray = ByteArray(0)): ByteArray =
        aead.encrypt(plaintext, associatedData)

    fun decrypt(ciphertext: ByteArray, associatedData: ByteArray = ByteArray(0)): ByteArray =
        aead.decrypt(ciphertext, associatedData)
}

Biometric Authentication: BiometricPrompt

BiometricPrompt shows the system’s own fingerprint/face authentication UI and reports success, failure or error back to the app without the app ever handling raw biometric data — the OS and the secure hardware own that entirely. BiometricManager.canAuthenticate() must be checked first, since availability depends on enrolled biometrics and device hardware.

// feature/settings/src/main/kotlin/com/example/trailmate/feature/settings/BiometricUnlock.kt
package com.example.trailmate.feature.settings

import androidx.biometric.BiometricManager
import androidx.biometric.BiometricManager.Authenticators.BIOMETRIC_STRONG
import androidx.biometric.BiometricPrompt
import androidx.fragment.app.FragmentActivity
import androidx.core.content.ContextCompat

fun promptBiometricUnlock(activity: FragmentActivity, onSuccess: () -> Unit) {
    val manager = BiometricManager.from(activity)
    if (manager.canAuthenticate(BIOMETRIC_STRONG) != BiometricManager.BIOMETRIC_SUCCESS) return

    val prompt = BiometricPrompt(
        activity,
        ContextCompat.getMainExecutor(activity),
        object : BiometricPrompt.AuthenticationCallback() {
            override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
                onSuccess()
            }
        }
    )

    val info = BiometricPrompt.PromptInfo.Builder()
        .setTitle("Unlock TrailMate")
        .setSubtitle("Confirm it's you to view your saved hikes")
        .setAllowedAuthenticators(BIOMETRIC_STRONG)
        .setNegativeButtonText("Use account password instead")
        .build()

    prompt.authenticate(info)
}

Passing a CryptoObject wrapping a Keystore Cipher into prompt.authenticate(info, cryptoObject) ties the biometric result to that specific cryptographic operation — the key configured with setUserAuthenticationRequired(true) becomes usable only immediately after a successful biometric check, which is strictly stronger than treating a plain boolean "authenticated" callback as a gate, since a boolean gate can in principle be bypassed by any code path that skips the call, while a CryptoObject-bound key cannot be used at all without the hardware having just verified the user.

Credential Manager and Passkeys

CredentialManager, the AndroidX successor to the deprecated Smart Lock/One Tap APIs, unifies passwords, sign-in with Google, and passkeys (WebAuthn/FIDO2 discoverable credentials, phishing-resistant by construction since they are bound to the origin they were created for) behind one request/response API, letting the system pick the best available credential type and UI for the user rather than the app juggling three separate flows.

// feature/settings/src/main/kotlin/com/example/trailmate/feature/settings/PasskeySignIn.kt
package com.example.trailmate.feature.settings

import androidx.credentials.CredentialManager
import androidx.credentials.GetCredentialRequest
import androidx.credentials.GetPasswordOption
import androidx.credentials.GetPublicKeyCredentialOption
import android.content.Context

suspend fun signInWithPasskeyOrPassword(context: Context, requestJson: String) {
    val credentialManager = CredentialManager.create(context)
    val request = GetCredentialRequest(
        credentialOptions = listOf(
            GetPublicKeyCredentialOption(requestJson = requestJson),
            GetPasswordOption()
        )
    )
    val response = credentialManager.getCredential(context, request)
    // response.credential is either a PublicKeyCredential (passkey) or a PasswordCredential;
    // the resulting assertion or password is exchanged for tokens as described in
    // Native and Mobile Apps.
}

See Android Developers — Credential Manager and Native and Mobile Apps for how the resulting credential is exchanged for OAuth tokens against TrailMate’s backend — the RFC 8252 rule against embedded WebViews for that exchange applies regardless of which credential type signed the user in.

Network Security Config

The Network Security Config (network_security_config.xml, referenced from the manifest via android:networkSecurityConfig) is a declarative policy for the app’s own outgoing TLS connections: it can forbid cleartext (http://) traffic app-wide, pin specific certificates for a domain, and control whether user-added or debug-only CAs are trusted — all without touching networking code.

<!-- app/src/main/res/xml/network_security_config.xml -->
<network-security-config>
    <base-config cleartextTrafficPermitted="false">
        <trust-anchors>
            <certificates src="system" />
        </trust-anchors>
    </base-config>
    <debug-overrides>
        <trust-anchors>
            <certificates src="system" />
            <certificates src="user" />
        </trust-anchors>
    </debug-overrides>
</network-security-config>
<!-- app/src/main/AndroidManifest.xml -->
<application
    android:networkSecurityConfig="@xml/network_security_config"
    ... >
</application>

See Android Developers — Network security configuration. Since API 28, cleartext traffic is disabled by default even without this file, so the config above is mainly useful for the <debug-overrides> block, which lets a debug build trust a locally installed CA (for inspecting TrailApi traffic through a proxy) without weakening the release build at all.

WebView Hardening

Any WebView TrailMate embeds — for example to show a trail’s source attribution page — must never load untrusted, remotely supplied HTML with JavaScript enabled unless it also disables addJavascriptInterface bridges to anything sensitive: a WebView with JavaScript on and a bridge object added is equivalent, from a security standpoint, to giving that page’s script a callable API into the app’s own code, and RFC 8252 forbids using an embedded WebView for an OAuth login flow specifically because of this, as covered on Native and Mobile Apps. Baseline hardening for any WebView that must remain:

  • settings.javaScriptEnabled = false unless the loaded content genuinely requires script execution.

  • Never call addJavascriptInterface() with an object exposing anything privileged, and never on a WebView that can navigate to an origin the app does not control.

  • Restrict navigation with a WebViewClient.shouldOverrideUrlLoading() allow-list rather than letting the WebView follow arbitrary redirects.

  • Keep the system WebView package updated — it ships and patches independently of the app itself through Play, which is also why hand-rolled login flows benefit from Custom Tabs or the platform browser instead.

Exported Component Risks

An activity, service, broadcast receiver or content provider is exported — callable by other apps on the device — either explicitly (android:exported="true") or, on targetSdk below 31, implicitly whenever it declares an <intent-filter>. Since API 31, every component with an intent filter must set android:exported explicitly, which closed a long-standing class of accidental exposure where a developer added a filter for their own app’s internal navigation and unknowingly made the component callable by any app on the device. The risk is concrete: an exported activity that trusts its incoming Intent extras without validation can be launched by a malicious app with attacker-controlled extras; an exported, unprotected BroadcastReceiver can be triggered by any app sending the matching action; a PendingIntent handed to another process (a notification action, for example) that was built as mutable can have its underlying intent altered by that other process unless it is built with PendingIntent.FLAG_IMMUTABLE, which has been required (mutability must be explicit) since API 31.

<!-- app/src/main/AndroidManifest.xml -->
<activity
    android:name=".feature.trails.TrailDetailActivity"
    android:exported="false" />

<receiver
    android:name=".core.data.SyncCompleteReceiver"
    android:exported="false" />

<!-- Exported deliberately: this one handles a deep link from outside the app. -->
<activity android:name=".feature.trails.TrailDeepLinkActivity" android:exported="true">
    <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="https" android:host="trailmate.example.com" />
    </intent-filter>
</activity>

The rule TrailMate follows is to default every component to android:exported="false" and flip it only for the handful that genuinely need to be reachable from outside the app (a deep-link activity, a share-target activity), and to treat any data arriving through an exported component’s Intent — extras, the data URI, the action — as untrusted input requiring the same validation as data from the network.

R8: Shrinking and Obfuscation

R8 is the compiler and optimizer AGP runs on release builds (superseding the standalone ProGuard tool, though it still reads ProGuard-syntax rule files): it shrinks the app by removing unreachable code and resources, and obfuscates it by renaming classes, methods and fields to short, meaningless names, which raises the cost of reverse-engineering a release APK/AAB even though it is not a substitute for the Keystore-backed protections above — obfuscated code can still be decompiled and read, just with less helpful names.

// app/build.gradle.kts
android {
    buildTypes {
        release {
            isMinifyEnabled = true
            isShrinkResources = true
            proguardFiles(
                getDefaultProguardFile("proguard-android-optimize.txt"),
                "proguard-rules.pro"
            )
        }
    }
}
# app/proguard-rules.pro
# Keep DTOs deserialized by kotlinx.serialization reflectively.
-keep @kotlinx.serialization.Serializable class com.example.trailmate.core.network.** { *; }

See Android Developers — Shrink, obfuscate, and optimize your app. A class referenced only through reflection, kotlinx.serialization, or a JNI signature is invisible to R8’s static reachability analysis and must be listed in a -keep rule, otherwise it is removed as apparently unused and the app crashes at runtime with a ClassNotFoundException; this is the single most common R8 migration bug and the reason a release build must always be tested, not only compiled, before shipping.

Play Integrity API

The Play Integrity API lets TrailMate’s backend ask, for a given request, whether it is truly talking to an unmodified TrailMate binary running on a genuine, unlocked-bootloader-free device with Play services — rather than a repackaged APK, an emulator farm used for abuse, or a rooted device running a tampered build. The app requests an integrity token tied to a server-generated nonce and forwards it to the backend, which verifies it server-side against Google’s decryption keys; TrailMate’s sync endpoint uses this to reject sync requests from builds that fail the check, on top of (never instead of) the OAuth bearer-token authentication described on Native and Mobile Apps.

// core/network/src/main/kotlin/com/example/trailmate/core/network/IntegrityChecker.kt
package com.example.trailmate.core.network

import com.google.android.play.core.integrity.IntegrityManagerFactory
import com.google.android.play.core.integrity.IntegrityTokenRequest

suspend fun requestIntegrityToken(context: android.content.Context, serverNonce: String): String {
    val integrityManager = IntegrityManagerFactory.create(context)
    val request = IntegrityTokenRequest.builder()
        .setNonce(serverNonce)
        .build()
    val response = integrityManager.requestIntegrityToken(request).await()
    return response.token()
}