Show permission states in the app and let the user fix them

Menu → Permissions lists precise location, location all the time,
notifications and the battery exemption. "Allow" asks again, or opens
the system settings once Android stops showing the dialog. The status
line warns while a needed one is missing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
AuthorKonata <konata@posteo.jp>
Date
Commitb89da87b8c13afa2a47e95c658b6368e8ea59c83
Parenta4a58b8
2 files changed, 124 insertions(+), 1 deletion(-)
▾Mandroid/app/src/main/java/org/opentracker/MainActivity.kt
@@ -45,6 +45,8 @@ class MainActivity : Activity() {
private var setupError: TextView? = null
/** The permission steps are running. The switch stays on meanwhile, although tracking has not started yet. */
private var starting = false
/** The rows of the open permissions dialog, refilled when the user returns from system settings. */
private var permissionRows: LinearLayout? = null
private val listener = SharedPreferences.OnSharedPreferenceChangeListener { _, key ->
if (key == "token") render() else showStatus()
}
@@ -68,6 +70,7 @@ class MainActivity : Activity() {
prefs.raw.registerOnSharedPreferenceChangeListener(listener)
webView?.onResume()
showStatus()
permissionRows?.let(::fillPermissions)
// Someone is looking, so waiting points go out now.
if (prefs.tracking && prefs.token != null) runCatching { TrackerService.start(this, TrackerService.ACTION_UPLOAD) }
}
@@ -137,7 +140,7 @@ class MainActivity : Activity() {
gravity = Gravity.CENTER_VERTICAL
setPadding(dp(16), dp(4), dp(8), dp(4))
}
val statusView = TextView(this)
val statusView = TextView(this).apply { setOnClickListener { if (missingPermissions()) showPermissions() } }
val toggle = Switch(this).apply {
text = "Tracking"
// A click, not a checked-change listener: showStatus also sets the switch, and that must not start or stop anything.
@@ -197,12 +200,17 @@ class MainActivity : Activity() {
if (last > 0) "sent ${DateFormat.getTimeFormat(this).format(last)}" else "nothing sent yet",
prefs.queued.takeIf { it > 0 }?.let { "$it waiting" },
prefs.error,
if (missingPermissions()) "Some permissions are missing" else null,
).joinToString(" · ")
}
}
private fun showMenu(anchor: View) {
val menu = PopupMenu(this, anchor)
menu.menu.add("Permissions").setOnMenuItemClickListener {
showPermissions()
true
}
menu.menu.add("Web UI").setOnMenuItemClickListener {
if (!openInBrowser("${prefs.url}/")) showError("No browser found.")
true
@@ -310,6 +318,14 @@ class MainActivity : Activity() {
private fun startTracking() {
starting = true
if (!granted(Manifest.permission.ACCESS_FINE_LOCATION)) {
if (!canAsk(Manifest.permission.ACCESS_FINE_LOCATION, "location")) {
// Android shows no dialog anymore. Only the system settings can grant it now.
starting = false
showStatus()
showPermissions()
return
}
prefs.markAsked("location")
requestPermissions(
arrayOf(Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION),
REQUEST_LOCATION,
@@ -374,7 +390,111 @@ class MainActivity : Activity() {
.show()
}
// Permissions dialog: what is allowed, and a way to fix what is not, also after "don't ask again".
/** `needed` is false where only a convenience is lost, so the status line does not warn about it. */
private class Permission(
val title: String,
val loss: String,
val granted: Boolean,
val needed: Boolean = true,
val fix: (() -> Unit)?,
)
private fun permissions(): List<Permission> {
val fine = granted(Manifest.permission.ACCESS_FINE_LOCATION)
val battery = getSystemService(PowerManager::class.java).isIgnoringBatteryOptimizations(packageName)
return buildList {
add(Permission("Precise location", "Without it there is no tracking.", fine) {
ask(Manifest.permission.ACCESS_FINE_LOCATION, "location", Manifest.permission.ACCESS_COARSE_LOCATION)
})
add(
Permission(
"Location all the time",
"Without it tracking does not restart after a reboot.",
granted(Manifest.permission.ACCESS_BACKGROUND_LOCATION),
// Android grants this only on top of precise location.
fix = if (fine) ({ ask(Manifest.permission.ACCESS_BACKGROUND_LOCATION, "background") }) else null,
),
)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
add(
Permission(
"Notifications",
"Without it the tracking notification is hidden.",
granted(Manifest.permission.POST_NOTIFICATIONS),
needed = false,
) { ask(Manifest.permission.POST_NOTIFICATIONS, "notifications") },
)
}
add(Permission("Run in the background", "Without it some phones stop tracking to save battery.", battery) {
@SuppressLint("BatteryLife")
val request = Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS, Uri.parse("package:$packageName"))
runCatching { startActivity(request) }
})
}
}
private fun missingPermissions(): Boolean = permissions().any { !it.granted && it.needed }
/** Android stops showing the dialog after the user refused twice. Before the first request it shows it, too. */
private fun canAsk(permission: String, step: String) =
!prefs.asked(step) || shouldShowRequestPermissionRationale(permission)
private fun ask(permission: String, step: String, vararg with: String) {
if (!canAsk(permission, step)) {
runCatching {
startActivity(Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, Uri.parse("package:$packageName")))
}
return
}
prefs.markAsked(step)
requestPermissions(arrayOf(permission, *with), REQUEST_FIX)
}
private fun showPermissions() {
val rows = LinearLayout(this).apply {
orientation = LinearLayout.VERTICAL
setPadding(dp(24), dp(8), dp(24), 0)
}
fillPermissions(rows)
permissionRows = rows
AlertDialog.Builder(this)
.setTitle("Permissions")
.setView(ScrollView(this).apply { addView(rows) })
.setPositiveButton("Close", null)
.setOnDismissListener { permissionRows = null }
.show()
}
private fun fillPermissions(rows: LinearLayout) {
rows.removeAllViews()
for (p in permissions()) {
rows.addView(TextView(this).apply {
text = p.title
setTextSize(TypedValue.COMPLEX_UNIT_SP, 16f)
setPadding(0, dp(12), 0, 0)
})
rows.addView(TextView(this).apply {
text = if (p.granted) "Allowed" else "Not allowed. ${p.loss}"
if (!p.granted) setTextColor(0xffdc2626.toInt())
})
val fix = p.fix
if (!p.granted && fix != null) {
rows.addView(Button(this).apply {
text = "Allow"
setOnClickListener { fix() }
})
}
}
}
override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<out String>, results: IntArray) {
if (requestCode == REQUEST_FIX) {
permissionRows?.let(::fillPermissions)
showStatus()
return
}
if (requestCode == REQUEST_LOCATION && !granted(Manifest.permission.ACCESS_FINE_LOCATION)) {
prefs.error = "Tracking needs the location permission."
starting = false
@@ -387,6 +507,7 @@ class MainActivity : Activity() {
companion object {
private const val REQUEST_LOCATION = 1
private const val REQUEST_OTHER = 2
private const val REQUEST_FIX = 3
private fun ByteArray.toHex() = joinToString("") { "%02x".format(it) }
}
▾Mdocs/android.md
@@ -177,6 +177,8 @@ The constants above become per-device settings. They are edited in the web UI, s
| `INTERNET`, `ACCESS_NETWORK_STATE` | uploads, offline detection |
| `REQUEST_IGNORE_BATTERY_OPTIMIZATIONS` | the exemption request |
Menu → Permissions shows each item as allowed or not, with an "Allow" button. The button asks again, or opens the system settings once Android no longer shows the dialog. While location, "all the time" or the battery exemption is missing, the status line says so, and tapping it opens the same list.
The permission flow asks in steps: fine location, then "Allow all the time" in system settings, then notifications, then the battery exemption. Each step explains why first. The app works without background location, but then tracking does not restart after a reboot.
## Steps