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. |
|
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. |
|
Special |
Through a dedicated system settings screen the app deep-links the user to — never through the standard runtime dialog. |
|
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) andACCESS_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 requestedACCESS_FINE_LOCATION. TrailMate must request both permissions in the sameRequestMultiplePermissionscall and check the result forACCESS_FINE_LOCATIONspecifically before relying on GPS-grade accuracy, falling back to a coarser hike track when onlyACCESS_COARSE_LOCATIONwas granted. -
ACCESS_BACKGROUND_LOCATIONis 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 typelocation, covered on Background Work: WorkManager, Services and Alarms) is started from the visible "Start recording" button, and alocationforeground 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_LOCATIONis 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_LOCATIONfor alocationforeground 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_LOCATIONwhen approximate accuracy is enough for a feature (for example, showing nearby trail regions on the list screen) and reserveACCESS_FINE_LOCATIONfor 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 noREAD_MEDIA_IMAGESgrant 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
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
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 = falseunless the loaded content genuinely requires script execution. -
Never call
addJavascriptInterface()with an object exposing anything privileged, and never on aWebViewthat can navigate to an origin the app does not control. -
Restrict navigation with a
WebViewClient.shouldOverrideUrlLoading()allow-list rather than letting theWebViewfollow 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()
}