MainActivity.kt
⎇
Raw
1package org.opentracker
2
3import android.Manifest
4import android.annotation.SuppressLint
5import android.app.Activity
6import android.app.AlertDialog
7import android.content.ActivityNotFoundException
8import android.content.Intent
9import android.content.SharedPreferences
10import android.content.pm.PackageManager
11import android.net.Uri
12import android.os.Build
13import android.os.Bundle
14import android.os.PowerManager
15import android.provider.Settings
16import android.text.InputType
17import android.text.format.DateFormat
18import android.util.TypedValue
19import android.view.Gravity
20import android.view.View
21import android.view.WindowInsets
22import android.webkit.JavascriptInterface
23import android.webkit.WebResourceRequest
24import android.webkit.WebView
25import android.webkit.WebViewClient
26import android.widget.Button
27import android.widget.EditText
28import android.widget.ImageButton
29import android.widget.LinearLayout
30import android.widget.PopupMenu
31import android.widget.ScrollView
32import android.widget.Switch
33import android.widget.TextView
34import androidx.browser.customtabs.CustomTabsIntent
35import java.security.MessageDigest
36import java.security.SecureRandom
37import 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. */
40class 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