MainActivity.kt
| 1 | package org.opentracker |
| 2 | |
| 3 | import android.Manifest |
| 4 | import android.annotation.SuppressLint |
| 5 | import android.app.Activity |
| 6 | import android.app.AlertDialog |
| 7 | import android.content.ActivityNotFoundException |
| 8 | import android.content.Intent |
| 9 | import android.content.SharedPreferences |
| 10 | import android.content.pm.PackageManager |
| 11 | import android.net.Uri |
| 12 | import android.os.Build |
| 13 | import android.os.Bundle |
| 14 | import android.os.PowerManager |
| 15 | import android.provider.Settings |
| 16 | import android.text.InputType |
| 17 | import android.text.format.DateFormat |
| 18 | import android.util.TypedValue |
| 19 | import android.view.Gravity |
| 20 | import android.view.View |
| 21 | import android.view.WindowInsets |
| 22 | import android.webkit.JavascriptInterface |
| 23 | import android.webkit.WebResourceRequest |
| 24 | import android.webkit.WebView |
| 25 | import android.webkit.WebViewClient |
| 26 | import android.widget.Button |
| 27 | import android.widget.EditText |
| 28 | import android.widget.ImageButton |
| 29 | import android.widget.LinearLayout |
| 30 | import android.widget.PopupMenu |
| 31 | import android.widget.ScrollView |
| 32 | import android.widget.Switch |
| 33 | import android.widget.TextView |
| 34 | import androidx.browser.customtabs.CustomTabsIntent |
| 35 | import java.security.MessageDigest |
| 36 | import java.security.SecureRandom |
| 37 | import kotlin.concurrent.thread |
| 38 | |
| 39 | /** Shows the setup screen until the app has a device token, then this device's map and the tracking switch. */ |
| 40 | class MainActivity : Activity() { |
| 41 | private lateinit var prefs: Prefs |
| 42 | private var webView: WebView? = null |
| 43 | private var status: TextView? = null |
| 44 | private var switch: Switch? = null |
| 45 | private var setupError: TextView? = null |
| 46 | /** The permission steps are running. The switch stays on meanwhile, although tracking has not started yet. */ |
| 47 | private var starting = false |
| 48 | private val listener = SharedPreferences.OnSharedPreferenceChangeListener { _, key -> |
| 49 | if (key == "token") render() else showStatus() |
| 50 | } |
| 51 | |
| 52 | override fun onCreate(savedInstanceState: Bundle?) { |
| 53 | super.onCreate(savedInstanceState) |
| 54 | // Our own top bar handles the edge-to-edge insets. The platform action bar would cover the content. |
| 55 | actionBar?.hide() |
| 56 | prefs = Prefs(this) |
| 57 | render() |
| 58 | handle(intent) |
| 59 | } |
| 60 | |
| 61 | override fun onNewIntent(intent: Intent) { |
| 62 | super.onNewIntent(intent) |
| 63 | handle(intent) |
| 64 | } |
| 65 | |
| 66 | override fun onResume() { |
| 67 | super.onResume() |
| 68 | prefs.raw.registerOnSharedPreferenceChangeListener(listener) |
| 69 | webView?.onResume() |
| 70 | showStatus() |
| 71 | // Someone is looking, so waiting points go out now. |
| 72 | if (prefs.tracking && prefs.token != null) runCatching { TrackerService.start(this, TrackerService.ACTION_UPLOAD) } |
| 73 | } |
| 74 | |
| 75 | override fun onPause() { |
| 76 | prefs.raw.unregisterOnSharedPreferenceChangeListener(listener) |
| 77 | webView?.onPause() |
| 78 | super.onPause() |
| 79 | } |
| 80 | |
| 81 | private fun render() { |
| 82 | webView?.destroy() |
| 83 | webView = null |
| 84 | val content = if (prefs.token == null) setupView() else mainView() |
| 85 | content.setOnApplyWindowInsetsListener { v, insets -> |
| 86 | val bars = insets.getInsets(WindowInsets.Type.systemBars() or WindowInsets.Type.ime()) |
| 87 | v.setPadding(bars.left, bars.top, bars.right, bars.bottom) |
| 88 | insets |
| 89 | } |
| 90 | setContentView(content) |
| 91 | } |
| 92 | |
| 93 | private fun dp(value: Int) = |
| 94 | TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, value.toFloat(), resources.displayMetrics).toInt() |
| 95 | |
| 96 | private fun setupView(): View { |
| 97 | val url = EditText(this).apply { |
| 98 | hint = "https://track.example.com" |
| 99 | inputType = InputType.TYPE_TEXT_VARIATION_URI |
| 100 | setText(prefs.url ?: "") |
| 101 | } |
| 102 | val error = TextView(this).apply { |
| 103 | setTextColor(0xffdc2626.toInt()) |
| 104 | text = prefs.error |
| 105 | } |
| 106 | setupError = error |
| 107 | val column = LinearLayout(this).apply { |
| 108 | orientation = LinearLayout.VERTICAL |
| 109 | setPadding(dp(24), dp(24), dp(24), dp(24)) |
| 110 | addView(TextView(this@MainActivity).apply { |
| 111 | text = "Connect to your server" |
| 112 | setTextSize(TypedValue.COMPLEX_UNIT_SP, 22f) |
| 113 | }) |
| 114 | addView(TextView(this@MainActivity).apply { |
| 115 | text = "Enter the address of your opentracker server. You then sign in in the browser and confirm this phone." |
| 116 | setPadding(0, dp(8), 0, dp(16)) |
| 117 | }) |
| 118 | addView(url) |
| 119 | addView(Button(this@MainActivity).apply { |
| 120 | text = "Connect in browser" |
| 121 | setOnClickListener { pair(url.text.toString()) } |
| 122 | }) |
| 123 | addView(Button(this@MainActivity).apply { |
| 124 | text = "Paste a device token" |
| 125 | setOnClickListener { pasteToken(url.text.toString()) } |
| 126 | }) |
| 127 | addView(error) |
| 128 | } |
| 129 | return ScrollView(this).apply { addView(column) } |
| 130 | } |
| 131 | |
| 132 | @SuppressLint("SetJavaScriptEnabled") |
| 133 | private fun mainView(): View { |
| 134 | val url = prefs.url!! |
| 135 | val bar = LinearLayout(this).apply { |
| 136 | orientation = LinearLayout.HORIZONTAL |
| 137 | gravity = Gravity.CENTER_VERTICAL |
| 138 | setPadding(dp(16), dp(4), dp(8), dp(4)) |
| 139 | } |
| 140 | val statusView = TextView(this) |
| 141 | val toggle = Switch(this).apply { |
| 142 | text = "Tracking" |
| 143 | // A click, not a checked-change listener: showStatus also sets the switch, and that must not start or stop anything. |
| 144 | setOnClickListener { if (isChecked) startTracking() else stopTracking() } |
| 145 | } |
| 146 | val more = ImageButton(this).apply { |
| 147 | setImageResource(R.drawable.ic_more) |
| 148 | background = null |
| 149 | contentDescription = "More" |
| 150 | setOnClickListener { showMenu(it) } |
| 151 | } |
| 152 | bar.addView(statusView, LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f)) |
| 153 | bar.addView(toggle) |
| 154 | bar.addView(more) |
| 155 | status = statusView |
| 156 | switch = toggle |
| 157 | |
| 158 | val web = WebView(this).apply { |
| 159 | settings.javaScriptEnabled = true |
| 160 | settings.domStorageEnabled = true |
| 161 | // The page reads the token from here. Navigation stays on the server, so no other page can ask. |
| 162 | addJavascriptInterface(Bridge(), "OtApp") |
| 163 | webViewClient = object : WebViewClient() { |
| 164 | override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean { |
| 165 | val target = request.url |
| 166 | val server = Uri.parse(url) |
| 167 | if (target.scheme == server.scheme && target.authority == server.authority) return false |
| 168 | runCatching { startActivity(Intent(Intent.ACTION_VIEW, target)) } |
| 169 | return true |
| 170 | } |
| 171 | } |
| 172 | loadUrl("$url/#device") |
| 173 | } |
| 174 | webView = web |
| 175 | showStatus() |
| 176 | return LinearLayout(this).apply { |
| 177 | orientation = LinearLayout.VERTICAL |
| 178 | addView(bar) |
| 179 | addView(web, LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, 0, 1f)) |
| 180 | } |
| 181 | } |
| 182 | |
| 183 | private inner class Bridge { |
| 184 | @JavascriptInterface |
| 185 | fun token(): String? = prefs.token |
| 186 | } |
| 187 | |
| 188 | private fun showStatus() { |
| 189 | switch?.isChecked = prefs.tracking || starting |
| 190 | setupError?.text = prefs.error |
| 191 | val view = status ?: return |
| 192 | val last = prefs.lastUploadMs |
| 193 | view.text = when { |
| 194 | !prefs.tracking -> "Tracking is off" |
| 195 | else -> listOfNotNull( |
| 196 | prefs.mode, |
| 197 | if (last > 0) "sent ${DateFormat.getTimeFormat(this).format(last)}" else "nothing sent yet", |
| 198 | prefs.queued.takeIf { it > 0 }?.let { "$it waiting" }, |
| 199 | prefs.error, |
| 200 | ).joinToString(" · ") |
| 201 | } |
| 202 | } |
| 203 | |
| 204 | private fun showMenu(anchor: View) { |
| 205 | val menu = PopupMenu(this, anchor) |
| 206 | menu.menu.add("Web UI").setOnMenuItemClickListener { |
| 207 | if (!openInBrowser("${prefs.url}/")) showError("No browser found.") |
| 208 | true |
| 209 | } |
| 210 | menu.menu.add("Disconnect").setOnMenuItemClickListener { |
| 211 | AlertDialog.Builder(this) |
| 212 | .setTitle("Disconnect this phone?") |
| 213 | .setMessage("Tracking stops. The phone stays in Settings → Devices until you remove it there.") |
| 214 | .setPositiveButton("Disconnect") { _, _ -> |
| 215 | stopTracking() |
| 216 | prefs.token = null |
| 217 | } |
| 218 | .setNegativeButton("Cancel", null) |
| 219 | .show() |
| 220 | true |
| 221 | } |
| 222 | menu.show() |
| 223 | } |
| 224 | |
| 225 | private fun openInBrowser(url: String): Boolean = try { |
| 226 | CustomTabsIntent.Builder().build().launchUrl(this, Uri.parse(url)) |
| 227 | true |
| 228 | } catch (e: ActivityNotFoundException) { |
| 229 | false |
| 230 | } |
| 231 | |
| 232 | // Pairing: the browser creates a one-time code for the hash of a secret that only this app knows. |
| 233 | |
| 234 | private fun pair(input: String) { |
| 235 | val url = Api.normalize(input) ?: return showError("Enter the server address.") |
| 236 | val bytes = ByteArray(32).also { SecureRandom().nextBytes(it) } |
| 237 | val verifier = bytes.toHex() |
| 238 | prefs.url = url |
| 239 | prefs.pairVerifier = verifier |
| 240 | prefs.error = null |
| 241 | val challenge = MessageDigest.getInstance("SHA-256").digest(verifier.toByteArray()).toHex() |
| 242 | val name = Uri.encode(Settings.Global.getString(contentResolver, Settings.Global.DEVICE_NAME) ?: Build.MODEL) |
| 243 | if (!openInBrowser("$url/#pair?challenge=$challenge&name=$name")) { |
| 244 | showError("No browser found. Paste a device token instead.") |
| 245 | } |
| 246 | } |
| 247 | |
| 248 | private fun handle(intent: Intent?) { |
| 249 | val data = intent?.data ?: return |
| 250 | if (data.scheme != "opentracker" || data.host != "paired") return |
| 251 | setIntent(Intent()) |
| 252 | val code = data.getQueryParameter("code") ?: return |
| 253 | val url = prefs.url |
| 254 | val verifier = prefs.pairVerifier |
| 255 | if (url == null || verifier == null) return showError("Start connecting in the app first.") |
| 256 | thread { |
| 257 | val result = runCatching { Api(url).pair(code, verifier) } |
| 258 | runOnUiThread { |
| 259 | result |
| 260 | .onSuccess { connected(it) } |
| 261 | .onFailure { showError("Connecting failed. Start again. (${it.message})") } |
| 262 | } |
| 263 | } |
| 264 | } |
| 265 | |
| 266 | /** Accepts the `ot use-token <url> <token>` line from the web UI, or only the token. */ |
| 267 | private fun pasteToken(typedUrl: String) { |
| 268 | val input = EditText(this).apply { hint = "ot use-token https://… token" } |
| 269 | AlertDialog.Builder(this) |
| 270 | .setTitle("Device token") |
| 271 | .setMessage("Create a token in the web UI under Settings → Devices.") |
| 272 | .setView(input) |
| 273 | .setPositiveButton("Connect") { _, _ -> |
| 274 | val words = input.text.toString().trim().split(Regex("\\s+")) |
| 275 | val token = words.lastOrNull { it.matches(Regex("[0-9a-f]{64}")) } |
| 276 | ?: return@setPositiveButton showError("That is not a device token.") |
| 277 | val url = words.firstNotNullOfOrNull { w -> w.takeIf { it.contains("://") }?.let(Api::normalize) } |
| 278 | ?: Api.normalize(typedUrl) |
| 279 | ?: return@setPositiveButton showError("Enter the server address.") |
| 280 | thread { |
| 281 | val result = runCatching { Api(url, token).device() } |
| 282 | runOnUiThread { |
| 283 | result |
| 284 | .onSuccess { |
| 285 | prefs.url = url |
| 286 | connected(token) |
| 287 | } |
| 288 | .onFailure { showError("The server did not accept the token. (${it.message})") } |
| 289 | } |
| 290 | } |
| 291 | } |
| 292 | .setNegativeButton("Cancel", null) |
| 293 | .show() |
| 294 | } |
| 295 | |
| 296 | private fun connected(token: String) { |
| 297 | prefs.pairVerifier = null |
| 298 | prefs.error = null |
| 299 | prefs.token = token |
| 300 | render() |
| 301 | } |
| 302 | |
| 303 | private fun showError(message: String) { |
| 304 | prefs.error = message |
| 305 | setupError?.text = message |
| 306 | } |
| 307 | |
| 308 | // Tracking: each permission is asked once, with an explanation first. Only foreground location is required. |
| 309 | |
| 310 | private fun startTracking() { |
| 311 | starting = true |
| 312 | if (!granted(Manifest.permission.ACCESS_FINE_LOCATION)) { |
| 313 | requestPermissions( |
| 314 | arrayOf(Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION), |
| 315 | REQUEST_LOCATION, |
| 316 | ) |
| 317 | return |
| 318 | } |
| 319 | val notify = Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU |
| 320 | if (notify && !granted(Manifest.permission.POST_NOTIFICATIONS) && once("notifications")) { |
| 321 | requestPermissions(arrayOf(Manifest.permission.POST_NOTIFICATIONS), REQUEST_OTHER) |
| 322 | return |
| 323 | } |
| 324 | if (!granted(Manifest.permission.ACCESS_BACKGROUND_LOCATION) && once("background")) { |
| 325 | explain( |
| 326 | "Allow location all the time", |
| 327 | "Choose \"Allow all the time\" on the next screen. Then tracking can restart by itself after the phone reboots.", |
| 328 | ) { requestPermissions(arrayOf(Manifest.permission.ACCESS_BACKGROUND_LOCATION), REQUEST_OTHER) } |
| 329 | return |
| 330 | } |
| 331 | val power = getSystemService(PowerManager::class.java) |
| 332 | if (!power.isIgnoringBatteryOptimizations(packageName) && once("battery")) { |
| 333 | explain( |
| 334 | "Keep tracking running", |
| 335 | "Some phones stop background apps to save battery. Allow opentracker to run in the background, so tracking does not stop.", |
| 336 | ) { |
| 337 | @SuppressLint("BatteryLife") |
| 338 | val request = Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS, Uri.parse("package:$packageName")) |
| 339 | runCatching { startActivity(request) } |
| 340 | startTracking() |
| 341 | } |
| 342 | return |
| 343 | } |
| 344 | starting = false |
| 345 | prefs.tracking = true |
| 346 | prefs.error = null |
| 347 | TrackerService.start(this) |
| 348 | showStatus() |
| 349 | } |
| 350 | |
| 351 | private fun stopTracking() { |
| 352 | starting = false |
| 353 | prefs.tracking = false |
| 354 | stopService(Intent(this, TrackerService::class.java)) |
| 355 | showStatus() |
| 356 | } |
| 357 | |
| 358 | private fun granted(permission: String) = checkSelfPermission(permission) == PackageManager.PERMISSION_GRANTED |
| 359 | |
| 360 | /** True the first time only. */ |
| 361 | private fun once(step: String): Boolean { |
| 362 | if (prefs.asked(step)) return false |
| 363 | prefs.markAsked(step) |
| 364 | return true |
| 365 | } |
| 366 | |
| 367 | private fun explain(title: String, message: String, next: () -> Unit) { |
| 368 | AlertDialog.Builder(this) |
| 369 | .setTitle(title) |
| 370 | .setMessage(message) |
| 371 | .setPositiveButton("Continue") { _, _ -> next() } |
| 372 | .setNegativeButton("Skip") { _, _ -> startTracking() } |
| 373 | .setCancelable(false) |
| 374 | .show() |
| 375 | } |
| 376 | |
| 377 | override fun onRequestPermissionsResult(requestCode: Int, permissions: Array<out String>, results: IntArray) { |
| 378 | if (requestCode == REQUEST_LOCATION && !granted(Manifest.permission.ACCESS_FINE_LOCATION)) { |
| 379 | prefs.error = "Tracking needs the location permission." |
| 380 | starting = false |
| 381 | showStatus() |
| 382 | return |
| 383 | } |
| 384 | startTracking() |
| 385 | } |
| 386 | |
| 387 | companion object { |
| 388 | private const val REQUEST_LOCATION = 1 |
| 389 | private const val REQUEST_OTHER = 2 |
| 390 | |
| 391 | private fun ByteArray.toHex() = joinToString("") { "%02x".format(it) } |
| 392 | } |
| 393 | } |
| 394 |