Common Permission Dialogs Bugs and How to Catch Them
Common Permission Dialogs Bugs and How to Catch Them
Common Permission Dialogs Bugs and How to Catch Them
Permission dialogs are the gatekeepers between an app’s functionality and the device’s protected resources. When they behave incorrectly, users see confusing prompts, lose trust, or abandon the app entirely. This guide walks through the most frequent permission‑dialog bugs, explains why they appear, shows how they look to real people, and gives concrete steps to reproduce, detect, fix, and prevent each issue. The article ends with a test matrix, a symptom‑to‑fix reference table, and a short checklist you can bookmark for your next release.
---
1. Why Permission Dialogs Fail: Core Concepts
Before diving into individual bug patterns, it helps to understand the mechanics that underlie every runtime permission request on Android (the principles apply similarly to iOS, but the examples focus on Android because it exposes the most variability).
1.1. The Permission Lifecycle
- Declared in manifest –
. - Runtime check –
ContextCompat.checkSelfPermission()returnsPERMISSION_GRANTEDorPERMISSION_DENIED. - Rationale decision – If the user has previously denied and checked “Don’t ask again”,
shouldShowRequestPermissionRationale()returnsfalse. - Request launch –
ActivityResultContracts.RequestPermission(or the legacyrequestPermissions()) shows the system dialog. - Result callback –
onRequestPermissionsResult()or the ActivityResult API receives the user’s choice. - Persistence – The grant/deny state is stored by the system and survives process death, but not app reinstall unless the permission is declared
android:requestLegacyExternalStorage="true"(legacy) or the user resets it via Settings.
1.2. Common Failure Points
- State not persisted – the app forgets a previous grant and asks again.
- Race conditions – UI changes while the dialog is asynchronous, causing the request to appear behind other screens.
- Missing rationale – users deny because they don’t understand why the permission is needed.
- Improper handling of “Don’t ask again” – the app treats a permanent denial as a temporary one and keeps requesting.
- Background triggers – a service or broadcast receiver initiates a request when the app is not in the foreground, leading to confusing system‑level prompts.
Understanding these points makes it easier to spot where a bug originates and how to test for it.
---
2. Bug Pattern 1: Permission Denied After Grant
2.1. What Happens
The user grants a permission (e.g., LOCATION) via the system dialog, but a few seconds later the app behaves as if the permission is still denied—features that rely on the permission are disabled, or the app immediately re‑shows the request.
2.2. Root Cause
Most often this is a state‑synchronization bug: the app caches the permission status in a variable or singleton at launch and never updates it after the asynchronous callback finishes. If the cache is read before the callback runs, the stale “denied” value is used.
2.3. How to Reproduce
- Launch the app fresh (no prior grant).
- Trigger a feature that needs the permission (e.g., tap “Enable Location”).
- When the system dialog appears, press Allow.
- Immediately navigate away from the screen that initiated the request (e.g., press Back) and then return to the feature.
- Observe that the feature is still disabled or the dialog appears again.
2.4. Detection Strategies
- Manual: Follow the steps above; use Settings → Apps → [Your App] → Permissions to verify the grant is actually recorded.
- Automated: Write an Espresso test that grants the permission via
adb shell pm grantbefore launching the feature, then asserts that the UI element dependent on the permission is enabled. - Autonomous exploration: A tool like SUSA can be pointed at the APK; its “curious” persona will try the feature, grant the permission, navigate, and verify that the feature works without re‑prompting.
2.5. Fix
- Store the permission result in a single source of truth (e.g., a
ViewModelwithLiveDataorStateFlow). - In the callback, update that source before any UI reacts.
- If you use the ActivityResult API, collect the result in a
mutableStateOf(Compose) orLiveDataand observe it wherever you need the status.
class LocationViewModel : ViewModel() {
private val _hasLocation = MutableLiveData(false)
val hasLocation: LiveData<Boolean> = _hasLocation
fun requestLocation() {
if (ContextCompat.checkSelfPermission(
requireContext(),
Manifest.permission.ACCESS_FINE_LOCATION
) == PackageManager.PERMISSION_GRANTED) {
_hasLocation.value = true
} else {
requestPermissionLauncher.launch(
Manifest.permission.ACCESS_FINE_LOCATION
)
}
}
private val requestPermissionLauncher = registerForActivityResult(
ActivityResultContracts.RequestPermission()
) { granted ->
_hasLocation.value = granted
}
}
2.6. Prevention
- Never cache permission status in a plain field that is only set in
onCreate(). - Always read the status from the live source (ViewModel, repository, or direct
ContextCompat.checkSelfPermission()) right before you need it. - Add a unit test that simulates the callback delay (using
CountingIdlingResourceorrunBlockingTest) to confirm the UI updates after the grant.
---
3. Bug Pattern 2: Dialog Shown Multiple Times
3.1. What Happens
After the user grants a permission, the system dialog reappears on every subsequent app launch or even within the same session, creating a frustrating loop.
3.2. Root Cause
The app incorrectly treats a temporary denial as a reason to re‑request every time the feature is accessed, ignoring the fact that the user already granted it. This often stems from calling requestPermissions() inside onResume() or a ViewPager page change without checking the current state first.
3.3. How to Reproduce
- Install the app, launch it, and deny a permission (e.g., CAMERA) when prompted.
- Immediately grant it in the next prompt (press Allow).
- Close the app (swipe away from recent) and relaunch.
- Observe that the permission dialog appears again despite the grant being recorded in Settings.
3.4. Detection Strategies
- Manual: Use Settings → Apps → [Your App] → Permissions to confirm the permission is listed as Allowed, then relaunch the app and watch for the dialog.
- Automated: An UIAutomator test can launch the app, verify that no permission dialog is present on startup (
uiDevice.waitForExists(By.text("Allow"), 0)returns false), then navigate to the feature and assert the dialog does not reappear. - SUSA: The “impatient” persona will rapidly open and close the app; its built‑in permission‑tracking module logs each dialog appearance and flags repeats.
3.5. Fix
- Always check the current status before invoking the request launcher.
- Encapsulate the check‑and‑request logic in a reusable function that returns early if already granted.
fun maybeRequestPermission(
activity: FragmentActivity,
permission: String,
rationale: String,
requestCode: Int
) {
if (ContextCompat.checkSelfPermission(activity, permission) ==
PackageManager.PERMISSION_GRANTED) {
return
}
if (activity.shouldShowRequestPermissionRationale(permission)) {
// Show custom rationale UI (Snackbar, dialog)
showRationaleDialog(activity, rationale) {
requestPermissionLauncher.launch(permission)
}
} else {
requestPermissionLauncher.launch(permission)
}
}
3.6. Prevention
- Put the permission‑check logic in a single place (e.g., a
PermissionHelpersingleton) and call it from every entry point. - Write a contract test that verifies the helper never calls the request launcher when the permission is already granted.
- Use static analysis (e.g., Detekt rule) to flag any direct call to
requestPermissions()outside the helper.
---
4. Bug Pattern 3: Permission Request Shown in Wrong Context
4.1. What Happens
The permission dialog appears while the user is on a completely unrelated screen (e.g., settings page) or after they have navigated away from the feature that triggered it. The user may think the request is spurious and deny it out of confusion.
4.2. Root Cause
A broadcast receiver, foreground service, or WorkManager triggers the request based on a global event (e.g., network change) without checking whether the app is currently in the foreground or whether the relevant UI is visible. The request is posted to the system UI thread, which shows it atop whatever activity is currently resumed.
4.3. How to Reproduce
- Start a background task that requests the
ACCESS_FINE_LOCATIONpermission when the device switches from Wi‑Fi to mobile data. - Connect to Wi‑Fi, launch the app, navigate to the Settings screen.
- Switch to mobile data (enable airplane mode then disable Wi‑Fi).
- Observe the location permission dialog appearing over the Settings screen.
4.4. Detection Strategies
- Manual: Use
adb shell dumpsys activity activitiesto see the top resumed activity when the dialog appears; if it’s not the expected feature activity, you have the bug. - Automated: An Espresso test can idling register a
IdlingResourcethat watches for the dialog’s window token; assert that the token belongs to the expected activity. - SUSA: Its “adversarial” persona deliberately fires system events (connectivity changes, battery low) while navigating random screens, capturing any misplaced dialogs.
4.5. Fix
- Gate any permission request with a foreground check:
fun Context.isAppInForeground(): Boolean {
val activityManager = getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager
val runningApp = activityManager.getRunningAppProcesses()
?.firstOrNull { it.importance == ActivityManager.RunningAppProcessInfo.IMPORTANCE_FOREGROUND }
return runningApp?.processName == packageName
}
- Only launch the request if
isAppInForeground()returnstrue. - If the request must happen from a background worker, defer it until the app comes to the foreground (use a
LiveDataevent orBroadcastReceiverwithFLAG_RECEIVER_FOREGROUND).
4.6. Prevention
- Enforce a lint rule that any call to
requestPermissions()must be inside a class that has access toLifecycleOwner(Activity/Fragment) or must be preceded by a foreground guard. - Add unit tests for your background workers that mock the context and verify they never call the request launcher unless a flag
isForegroundAllowedis set.
---
5. Bug Pattern 4: Dialog Blocks UI Thread / Causes ANR
5.1. What Happens
When the permission dialog is shown, the app becomes unresponsive for a few seconds, leading to an “App Not Responding” (ANR) dialog or a noticeable lag in UI interactions.
5.2. Root Cause
Calling the permission request synchronously on the main thread (e.g., using ActivityCompat.requestPermissions() inside a clickListener that performs heavy work before the call) or performing expensive operations inside the result callback (e.g., initializing a camera, starting a location update) before returning control to the system.
5.3. How to Reproduce
- Add a heavy computation (e.g., JSON parsing of a 10 MB file) to the click handler that also requests the CAMERA permission.
- Tap the button; observe the UI freeze for the duration of the computation before the system dialog appears.
- With profiling enabled (
adb shell am profile start), you’ll see the main thread blocked.
5.4. Detection Strategies
- Manual: Enable “Show CPU usage” in Developer options and watch for spikes when the dialog appears.
- Automated: Use the Android Studio Profiler or
adb shell cmd activity start-activitywith the-Wflag to measure time to window focus; if > 5 s, flag as potential ANR. - SUSA: Its “power‑user” persona performs rapid interactions while monitoring frame timing; any frame > 16 ms during a permission flow is reported as a jitter bug.
5.5. Fix
- Keep the request call itself lightweight; it merely launches a system dialog and returns instantly.
- Move any heavy work after the permission result is received, preferably off the main thread (using
CoroutineScope(Dispatchers.IO)orExecutorService).
button.setOnClickListener {
// Fast check + request
if (ContextCompat.checkSelfPermission(this,
Manifest.permission.CAMERA) != PackageManager.PERMISSION_GRANTED) {
requestPermissionLauncher.launch(Manifest.permission.CAMERA)
return@setOnClickListener
}
// Permission already granted – do heavy work off‑main
lifecycleScope.launch(Dispatchers.IO) {
val photo = takeHighResPhoto() // suspending function
withContext(Dispatchers.Main) {
showPhoto(photo)
}
}
}
5.6. Prevention
- Add a strict mode policy in development to detect disk or network access on the main thread during permission flows.
- Write a unit test that uses
CountingIdlingResourceto ensure the main thread is idle for at least 200 ms after the request launcher is invoked before any heavy operation starts.
---
6. Bug Pattern 5: Missing Rationale Leading to Denial
6.1. What Happens
The user sees a system permission dialog with no explanation of why the app needs the permission, feels uneasy, and selects Deny. The app then loses functionality, and the user may not realize they can re‑enable it later.
6.2. Root Cause
The app calls requestPermissions() directly without first invoking shouldShowRequestPermissionRationale() to determine if a custom explanation is warranted. On Android, the system only shows a rationale when the user has previously denied the permission *and* has not checked “Don’t ask again”. Skipping this step wastes an opportunity to build trust.
6.3. How to Reproduce
- Launch the app, deny a permission (e.g., RECORD_AUDIO) when first prompted.
- Immediately trigger the feature again; the system dialog appears without any context.
- Observe that the user is likely to deny again because they don’t see why the app needs the mic.
6.4. Detection Strategies
- Manual: After a denial, trigger the feature again and verify that a custom Snackbar/dialog appears before the system prompt.
- Automated: An Espresso test can mock
shouldShowRequestPermissionRationale()to returntrueand assert that a view with the IDrationale_containeris visible before the request launcher fires. - SUSA: Its “novice” persona spends extra time reading on‑screen text; if it encounters a permission dialog without preceding explanatory text, it logs a missing‑rationale finding.
6.5. Fix
- Implement a rationale UI (Snackbar, dialog, or inline text) that explains the benefit, then launch the system request after the user acknowledges it.
private fun requestMicrophonePermission() {
if (ContextCompat.checkSelfPermission(this,
Manifest.permission.RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED) {
startRecording()
return
}
if (shouldShowRequestPermissionRationale(Manifest.permission.RECORD_AUDIO)) {
// Show custom explanation
Snackbar.make(findViewById(android.R.id.content),
"We need the mic to add voice notes to your memos.",
Snackbar.LENGTH_INDEFINITE)
.setAction("OK") {
requestPermissionLauncher.launch(
Manifest.permission.RECORD_AUDIO
)
}
.show()
} else {
// User has checked “Don’t ask again”; direct to settings
showPermissionSettingsRationale()
}
}
6.6. Prevention
- Create a PermissionFlow abstract class that forces subclasses to implement
provideRationale()andhandleResult(). - Add a unit test that verifies
shouldShowRequestPermissionRationale()is called before any call torequestPermissions()in all permission‑requesting pathways.
---
7. Bug Pattern 6: Permission Revoked at Runtime Not Handled
7.1. What Happens
While the app is running, the user goes to Settings and revokes a permission (e.g., turns off Location). The app continues to assume the permission is still granted, leading to crashes, silent failures, or misleading UI states (e.g., a map showing a stale location).
7.2. Root Cause
The app only checks permission status at launch or when a feature is first used, and never listens for runtime revocation events. Android does not broadcast a revocation, so the app must poll or rely on callbacks from the APIs that use the permission (e.g., FusedLocationProviderClient returns a failure result).
7.3. How to Reproduce
- Start the app and grant LOCATION permission.
- Begin a location‑tracking feature (see a live dot on the map).
- Open Settings → Apps → [Your App] → Permissions → toggle Location off.
- Return to the app; observe that the map stops updating, but no error is shown, or the app throws a
SecurityExceptionwhen trying to request updates.
7.4. Detection Strategies
- Manual: Follow the steps above and watch for error logs (
adb logcat) or UI changes. - Automated: Use
adb shell pm revokefrom a test script after starting the feature, then assert that the app either shows a permission‑missing UI or gracefully degrades. - SUSA: Its “elderly” persona simulates a user who frequently checks settings; it periodically revokes random permissions during a session and verifies that the app responds with a clear message or fallback.
7.5. Fix
- Listen for API‑level failures: Most location, camera, or sensor APIs return a specific error code when the permission is missing. Handle those errors and prompt the user to re‑grant.
- Poll periodically (only if necessary) using a
WorkManagerwith a low frequency (e.g., every 15 min) to checkContextCompat.checkSelfPermission()and update UI accordingly. - Provide a clear re‑grant path: Show a Snackbar with “Enable Location” that launches an intent to
Settings.ACTION_APPLICATION_DETAILS_SETTINGS.
fusedLocationClient.lastLocation.addOnCompleteListener { task ->
if (!task.isSuccessful) {
when (task.exception) {
is SecurityException -> {
// Permission likely revoked
showPermissionMissingSnackbar()
}
else -> // handle other errors
}
} else {
// success – update UI
}
}
7.6. Prevention
- Add a runtime permission guard wrapper around any API call that requires a dangerous permission; the wrapper checks the permission, makes the call, and on failure triggers a re‑request flow.
- Write a contract test that injects a mocked
FusedLocationProviderClientthat throwsSecurityExceptionand asserts that the UI shows a permission‑missing message.
---
8. Bug Pattern 7: Incorrect Handling of “Don’t Ask Again”
8.1. What Happens
The user denies a permission and checks Don’t ask again. The app continues to show the system dialog on every launch, or worse, it crashes because it assumes the permission will eventually be granted.
8.2. Root Cause
The app ignores the return value of shouldShowRequestPermissionRationale(). When it returns false, the correct action is to explain why the permission is needed and direct the user to Settings, not to re‑show the system prompt. Some developers mistakenly treat false as “still ask later”.
8.3. How to Reproduce
- Launch the app and deny a permission (e.g., READ_CONTACTS) while ticking Don’t ask again.
- Close the app and relaunch.
- Observe that the permission dialog appears again (incorrect) or that the app crashes when trying to read contacts.
8.4. Detection Strategies
- Manual: After a denial with the checkbox, relaunch the app and verify that no system dialog appears; instead, a rationale or Settings prompt should be shown.
- Automated: In an Espresso test, set
shouldShowRequestPermissionRationale()to returnfalsevia a mockActivity(using Robolectric or Mockito) and assert that the request launcher is not invoked. - SUSA: Its “accessibility” persona, which relies on clear cues, will notice repeated dialogs after a “Don’t ask again” selection and flag it as a permission‑handling defect.
8.5. Fix
if (ContextCompat.checkSelfPermission(this,
Manifest.permission.READ_CONTACTS) != PackageManager.PERMISSION_GRANTED) {
if (shouldShowRequestPermissionRationale(Manifest.permission.READ_CONTACTS)) {
showRationaleDialog() { requestPermissionLauncher.launch(Manifest.permission.READ_CONTACTS) }
} else {
// User has chosen “Don’t ask again”
showSettingsGuidance()
}
}
The showSettingsGuidance() function launches an intent to the app’s settings screen where the user can manually toggle the permission.
8.6. Prevention
- Enforce a code‑review rule: any call to
requestPermissions()must be preceded by a check ofshouldShowRequestPermissionRationale(). - Add a unit test that uses a
ShadowActivity(Robolectric) to simulate bothtrueandfalsereturn values and verifies the correct branch is taken.
---
9. Bug Pattern 8: Permission Dialog Appears Behind Other UI
9.1. What Happens
The system permission dialog is displayed, but it is obscured by a full‑screen loading spinner, a dialog from a third‑party library, or a system overlay (e.g., chat heads). The user taps elsewhere, thinks the app is frozen, and may force‑close it.
9.2. Root Cause
The app shows its own UI (often a ProgressBar or a custom dialog) before requesting the permission, and does not dismiss it when the system dialog appears. Because the system dialog is shown at the window level TYPE_APPLICATION_OVERLAY, it can still be covered if the app’s window has a higher z‑order or if the app uses WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE incorrectly.
9.3. How to Reproduce
- Trigger a feature that first shows a full‑screen loading indicator (e.g., while fetching a token).
- Immediately after the indicator appears, request the CAMERA permission.
- Observe that the loading indicator stays on top and the permission dialog is partially or fully hidden.
9.4. Detection Strategies
- Manual: Enable “Show layout bounds” in Developer options; you’ll see the permission dialog’s window rectangle underneath the app’s view hierarchy.
- Automated: Use UIAutomator to retrieve the window token of the permission dialog (
uiDevice.waitForExists(By.text("Allow"), 5000)) and then query the decor view’s window level; assert that it is not obscured by any view withFLAG_NOT_FOCUSABLE. - SUSA: Its “curious” persona will deliberately overlay loading spinners before permission requests and capture screenshots; image‑comparison detects if the dialog is fully visible.
9.5. Fix
- Dismiss any blocking UI before launching the permission request.
- If you need to show a loading indicator *after* the permission is granted (e.g., initializing the camera), move the indicator inside the result callback.
fun requestCameraPermission() {
// Hide any full‑screen blockers first
binding.progressBar.visibility = View.GONE
if (ContextCompat.checkSelfPermission(this,
Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED) {
startCameraPreview()
} else {
if (shouldShowRequestPermissionRationale(Manifest.permission.CAMERA)) {
showRationale { requestPermissionLauncher.launch(Manifest.permission.CAMERA) }
} else {
showSettingsGuidance()
}
}
}
9.6. Prevention
- Adopt a single‑source‑of‑truth UI state (e.g., a
SealedClassUIState withLoading,PermissionNeeded,Ready,Error). The UI layer reacts to the state and never shows multiple conflicting overlays simultaneously. - Add an automated UI test that verifies after a permission request is initiated, no view with
id = progress_baris visible (assertNotEquals(View.VISIBLE, binding.progressBar.visibility)).
---
10. Bug Pattern 9: Permission Request Triggered by Background Service
10.1. What Happens
A background service (e.g., a JobIntentService or WorkManager) decides it needs the READ_PHONE_STATE permission to read the device ID and calls requestPermissions() directly. Because the service has no UI context, the system shows the dialog, but the user may be in another app, leading to confusion and a high denial rate.
10.2. Root Cause
Missing foreground guard: background components lack a visible Activity or Fragment to host the permission rationale, yet they still attempt to request runtime permissions. The system allows the request, but the resulting dialog is detached from any app‑owned context.
10.3. How to Reproduce
- Start a background worker that periodically checks for telephony changes and, if the permission is missing, calls
requestPermissions(). - Put the app in the background (press Home).
- Wait for the worker to fire; observe the permission dialog appearing over whatever app is currently in the foreground (e.g., Chrome).
- Return to your app; notice that the dialog is still present, but the user may have already interacted with it blindly.
10.4. Detection Strategies
- Manual: Use
adb shell dumpsys activity servicesto see which service is running when the dialog appears, then verify that no foreground activity belongs to your app. - Automated: In an instrumentation test, start the worker via
WorkManager.getInstance().getWorkInfoByIdLiveData()and assert thatContextCompat.checkSelfPermission()is called before anyrequestPermissions(). - SUSA: Its “adversarial” persona will force background work while switching apps frequently; it logs any permission dialog that appears when the app is not in the foreground and marks it as a background‑request bug.
10.5. Fix
- Never request permissions from a pure background context. Instead, have the worker set a flag (e.g., store a
SharedPreferencesentrypending_phone_state = true) and then, when the app comes to the foreground, check the flag and launch the request from an Activity or Fragment. - If the background work truly requires the permission immediately (rare), use a foreground service with a persistent notification; the notification can host a rationale and the request can be launched from the notification’s pending intent.
class PhoneStateWorker(appContext: Context, params: WorkerParameters) :
CoroutineWorker(appContext, params) {
override suspend fun doWork(): Result {
val hasPermission = ContextCompat.checkSelfPermission(
applicationContext,
Manifest.permission.READ_PHONE_STATE
) == PackageManager.PERMISSION_GRANTED
if (!hasPermission) {
// Defer to UI
val prefs = PreferenceManager.getDefaultSharedPreferences(applicationContext)
prefs.edit().putBoolean("pending_phone_state", true).apply()
return Result.retry() // let the OS retry later; UI will handle it
}
// Proceed with work
// ...
return Result.success()
}
}
In the main Activity’s onResume():
override fun onResume() {
super.onResume()
val prefs = PreferenceManager.getDefaultSharedPreferences(this)
if (prefs.getBoolean("pending_phone_state", false)) {
prefs.edit().remove("pending_phone_state").apply()
maybeRequestPhoneStatePermission()
}
}
10.6. Prevention
- Add a lint check that flags any call to
requestPermissions()inside a class that does not extendActivity,Fragment, orAppCompatActivity. - Write a unit test for each
Workerimplementation that verifies it never calls the permission launcher directly.
---
11. Bug Pattern 10: Localization Issues in Dialog Text
11.1. What Happens
The permission dialog’s title or message is truncated, overlaps with the “Allow/Deny” buttons, or shows English text in a non‑English locale because the app supplies a custom rationale string that is not properly localized.
11.2. Root Cause
Developers sometimes hard‑place the rationale text in a layout or call setMessage() on an AlertDialog with a string literal, bypassing the strings.xml resource system. When the app runs on a device with a different language, the literal remains unchanged, causing layout overflow or confusing wording.
11.3. How to Reproduce
- Set the device language to Spanish (or any right‑to‑left language).
- Trigger a feature that shows a custom rationale dialog before the system permission request.
- Observe that the dialog’s text is either in English, exceeds the view bounds, or misaligns the buttons.
11.4. Detection Strategies
- Manual: Change language in Settings → System → Languages & input → Languages, then repeat the permission flow and visually inspect the dialog.
- Automated: Use Espresso to set the locale via
LocaleTestRuleand assert that the text retrieved from the rationale view matches the expected string resource for that locale (assertEquals(R.string.rationale_location, binding.rationaleText.text)). - SUSA: Its “global” persona cycles through a set of locales during each run; it captures screenshots of every dialog and runs OCR to verify that the text matches the localized resource and fits within the view bounds.
11.5. Fix
- Always reference string resources for any UI shown to the user,
Test Your App Autonomously
Upload your APK or URL. SUSA explores like 10 real users — finds bugs, accessibility violations, and security issues. No scripts.
Try SUSA Free