package org.opentracker import android.Manifest import android.annotation.SuppressLint import android.app.Activity import android.app.AlertDialog import android.content.ActivityNotFoundException import android.content.Intent import android.content.SharedPreferences import android.content.pm.PackageManager import android.net.Uri import android.os.Build import android.os.Bundle import android.os.PowerManager import android.provider.Settings import android.text.InputType import android.text.format.DateFormat import android.util.TypedValue import android.view.Gravity import android.view.View import android.view.WindowInsets import android.webkit.JavascriptInterface import android.webkit.WebResourceRequest import android.webkit.WebView import android.webkit.WebViewClient import android.widget.Button import android.widget.EditText import android.widget.ImageButton import android.widget.LinearLayout import android.widget.PopupMenu import android.widget.ScrollView import android.widget.Switch import android.widget.TextView import androidx.browser.customtabs.CustomTabsIntent import java.security.MessageDigest import java.security.SecureRandom import kotlin.concurrent.thread /** Shows the setup screen until the app has a device token, then this device's map and the tracking switch. */ class MainActivity : Activity() { private lateinit var prefs: Prefs private var webView: WebView? = null private var status: TextView? = null private var switch: Switch? = null 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 private val listener = SharedPreferences.OnSharedPreferenceChangeListener { _, key -> if (key == "token") render() else showStatus() } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) // Our own top bar handles the edge-to-edge insets. The platform action bar would cover the content. actionBar?.hide() prefs = Prefs(this) render() handle(intent) } override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) handle(intent) } override fun onResume() { super.onResume() prefs.raw.registerOnSharedPreferenceChangeListener(listener) webView?.onResume() showStatus() // Someone is looking, so waiting points go out now. if (prefs.tracking && prefs.token != null) runCatching { TrackerService.start(this, TrackerService.ACTION_UPLOAD) } } override fun onPause() { prefs.raw.unregisterOnSharedPreferenceChangeListener(listener) webView?.onPause() super.onPause() } private fun render() { webView?.destroy() webView = null val content = if (prefs.token == null) setupView() else mainView() content.setOnApplyWindowInsetsListener { v, insets -> val bars = insets.getInsets(WindowInsets.Type.systemBars() or WindowInsets.Type.ime()) v.setPadding(bars.left, bars.top, bars.right, bars.bottom) insets } setContentView(content) } private fun dp(value: Int) = TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, value.toFloat(), resources.displayMetrics).toInt() private fun setupView(): View { val url = EditText(this).apply { hint = "https://track.example.com" inputType = InputType.TYPE_TEXT_VARIATION_URI setText(prefs.url ?: "") } val error = TextView(this).apply { setTextColor(0xffdc2626.toInt()) text = prefs.error } setupError = error val column = LinearLayout(this).apply { orientation = LinearLayout.VERTICAL setPadding(dp(24), dp(24), dp(24), dp(24)) addView(TextView(this@MainActivity).apply { text = "Connect to your server" setTextSize(TypedValue.COMPLEX_UNIT_SP, 22f) }) addView(TextView(this@MainActivity).apply { text = "Enter the address of your opentracker server. You then sign in in the browser and confirm this phone." setPadding(0, dp(8), 0, dp(16)) }) addView(url) addView(Button(this@MainActivity).apply { text = "Connect in browser" setOnClickListener { pair(url.text.toString()) } }) addView(Button(this@MainActivity).apply { text = "Paste a device token" setOnClickListener { pasteToken(url.text.toString()) } }) addView(error) } return ScrollView(this).apply { addView(column) } } @SuppressLint("SetJavaScriptEnabled") private fun mainView(): View { val url = prefs.url!! val bar = LinearLayout(this).apply { orientation = LinearLayout.HORIZONTAL gravity = Gravity.CENTER_VERTICAL setPadding(dp(16), dp(4), dp(8), dp(4)) } val statusView = TextView(this) 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. setOnClickListener { if (isChecked) startTracking() else stopTracking() } } val more = ImageButton(this).apply { setImageResource(R.drawable.ic_more) background = null contentDescription = "More" setOnClickListener { showMenu(it) } } bar.addView(statusView, LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f)) bar.addView(toggle) bar.addView(more) status = statusView switch = toggle val web = WebView(this).apply { settings.javaScriptEnabled = true settings.domStorageEnabled = true // The page reads the token from here. Navigation stays on the server, so no other page can ask. addJavascriptInterface(Bridge(), "OtApp") webViewClient = object : WebViewClient() { override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean { val target = request.url val server = Uri.parse(url) if (target.scheme == server.scheme && target.authority == server.authority) return false runCatching { startActivity(Intent(Intent.ACTION_VIEW, target)) } return true } } loadUrl("$url/#device") } webView = web showStatus() return LinearLayout(this).apply { orientation = LinearLayout.VERTICAL addView(bar) addView(web, LinearLayout.LayoutParams(LinearLayout.LayoutParams.MATCH_PARENT, 0, 1f)) } } private inner class Bridge { @JavascriptInterface fun token(): String? = prefs.token } private fun showStatus() { switch?.isChecked = prefs.tracking || starting setupError?.text = prefs.error val view = status ?: return val last = prefs.lastUploadMs view.text = when { !prefs.tracking -> "Tracking is off" else -> listOfNotNull( prefs.mode, if (last > 0) "sent ${DateFormat.getTimeFormat(this).format(last)}" else "nothing sent yet", prefs.queued.takeIf { it > 0 }?.let { "$it waiting" }, prefs.error, ).joinToString(" · ") } } private fun showMenu(anchor: View) { val menu = PopupMenu(this, anchor) menu.menu.add("Web UI").setOnMenuItemClickListener { if (!openInBrowser("${prefs.url}/")) showError("No browser found.") true } menu.menu.add("Disconnect").setOnMenuItemClickListener { AlertDialog.Builder(this) .setTitle("Disconnect this phone?") .setMessage("Tracking stops. The phone stays in Settings → Devices until you remove it there.") .setPositiveButton("Disconnect") { _, _ -> stopTracking() prefs.token = null } .setNegativeButton("Cancel", null) .show() true } menu.show() } private fun openInBrowser(url: String): Boolean = try { CustomTabsIntent.Builder().build().launchUrl(this, Uri.parse(url)) true } catch (e: ActivityNotFoundException) { false } // Pairing: the browser creates a one-time code for the hash of a secret that only this app knows. private fun pair(input: String) { val url = Api.normalize(input) ?: return showError("Enter the server address.") val bytes = ByteArray(32).also { SecureRandom().nextBytes(it) } val verifier = bytes.toHex() prefs.url = url prefs.pairVerifier = verifier prefs.error = null val challenge = MessageDigest.getInstance("SHA-256").digest(verifier.toByteArray()).toHex() val name = Uri.encode(Settings.Global.getString(contentResolver, Settings.Global.DEVICE_NAME) ?: Build.MODEL) if (!openInBrowser("$url/#pair?challenge=$challenge&name=$name")) { showError("No browser found. Paste a device token instead.") } } private fun handle(intent: Intent?) { val data = intent?.data ?: return if (data.scheme != "opentracker" || data.host != "paired") return setIntent(Intent()) val code = data.getQueryParameter("code") ?: return val url = prefs.url val verifier = prefs.pairVerifier if (url == null || verifier == null) return showError("Start connecting in the app first.") thread { val result = runCatching { Api(url).pair(code, verifier) } runOnUiThread { result .onSuccess { connected(it) } .onFailure { showError("Connecting failed. Start again. (${it.message})") } } } } /** Accepts the `ot use-token ` line from the web UI, or only the token. */ private fun pasteToken(typedUrl: String) { val input = EditText(this).apply { hint = "ot use-token https://… token" } AlertDialog.Builder(this) .setTitle("Device token") .setMessage("Create a token in the web UI under Settings → Devices.") .setView(input) .setPositiveButton("Connect") { _, _ -> val words = input.text.toString().trim().split(Regex("\\s+")) val token = words.lastOrNull { it.matches(Regex("[0-9a-f]{64}")) } ?: return@setPositiveButton showError("That is not a device token.") val url = words.firstNotNullOfOrNull { w -> w.takeIf { it.contains("://") }?.let(Api::normalize) } ?: Api.normalize(typedUrl) ?: return@setPositiveButton showError("Enter the server address.") thread { val result = runCatching { Api(url, token).device() } runOnUiThread { result .onSuccess { prefs.url = url connected(token) } .onFailure { showError("The server did not accept the token. (${it.message})") } } } } .setNegativeButton("Cancel", null) .show() } private fun connected(token: String) { prefs.pairVerifier = null prefs.error = null prefs.token = token render() } private fun showError(message: String) { prefs.error = message setupError?.text = message } // Tracking: each permission is asked once, with an explanation first. Only foreground location is required. private fun startTracking() { starting = true if (!granted(Manifest.permission.ACCESS_FINE_LOCATION)) { requestPermissions( arrayOf(Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION), REQUEST_LOCATION, ) return } val notify = Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU if (notify && !granted(Manifest.permission.POST_NOTIFICATIONS) && once("notifications")) { requestPermissions(arrayOf(Manifest.permission.POST_NOTIFICATIONS), REQUEST_OTHER) return } if (!granted(Manifest.permission.ACCESS_BACKGROUND_LOCATION) && once("background")) { explain( "Allow location all the time", "Choose \"Allow all the time\" on the next screen. Then tracking can restart by itself after the phone reboots.", ) { requestPermissions(arrayOf(Manifest.permission.ACCESS_BACKGROUND_LOCATION), REQUEST_OTHER) } return } val power = getSystemService(PowerManager::class.java) if (!power.isIgnoringBatteryOptimizations(packageName) && once("battery")) { explain( "Keep tracking running", "Some phones stop background apps to save battery. Allow opentracker to run in the background, so tracking does not stop.", ) { @SuppressLint("BatteryLife") val request = Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS, Uri.parse("package:$packageName")) runCatching { startActivity(request) } startTracking() } return } starting = false prefs.tracking = true prefs.error = null TrackerService.start(this) showStatus() } private fun stopTracking() { starting = false prefs.tracking = false stopService(Intent(this, TrackerService::class.java)) showStatus() } private fun granted(permission: String) = checkSelfPermission(permission) == PackageManager.PERMISSION_GRANTED /** True the first time only. */ private fun once(step: String): Boolean { if (prefs.asked(step)) return false prefs.markAsked(step) return true } private fun explain(title: String, message: String, next: () -> Unit) { AlertDialog.Builder(this) .setTitle(title) .setMessage(message) .setPositiveButton("Continue") { _, _ -> next() } .setNegativeButton("Skip") { _, _ -> startTracking() } .setCancelable(false) .show() } override fun onRequestPermissionsResult(requestCode: Int, permissions: Array, results: IntArray) { if (requestCode == REQUEST_LOCATION && !granted(Manifest.permission.ACCESS_FINE_LOCATION)) { prefs.error = "Tracking needs the location permission." starting = false showStatus() return } startTracking() } companion object { private const val REQUEST_LOCATION = 1 private const val REQUEST_OTHER = 2 private fun ByteArray.toHex() = joinToString("") { "%02x".format(it) } } }