From 3899df92c2a5c7215b005c57b03214bb9e20ace0 Mon Sep 17 00:00:00 2001 From: Mygod Date: Mon, 18 Sep 2023 19:25:17 -0400 Subject: [PATCH 01/26] Initial draft of location following --- app/build.gradle.kts | 8 +- app/src/main/AndroidManifest.xml | 20 +++ app/src/main/java/be/mygod/reactmap/App.kt | 47 ++++++ .../be/mygod/reactmap/ConfigDialogFragment.kt | 90 +++++++++++ .../main/java/be/mygod/reactmap/Glocation.kt | 15 +- .../java/be/mygod/reactmap/MainActivity.kt | 103 +++--------- .../be/mygod/reactmap/ReactMapHttpEngine.kt | 41 +++++ .../java/be/mygod/reactmap/SiteController.kt | 18 +-- .../follower/BackgroundLocationReceiver.kt | 149 ++++++++++++++++++ .../mygod/reactmap/follower/LastLocation.kt | 11 ++ .../mygod/reactmap/follower/LocationSetter.kt | 117 ++++++++++++++ .../reactmap/util/AlertDialogFragment.kt | 73 +++++++++ .../{ => util}/CreateDynamicDocument.kt | 2 +- .../mygod/reactmap/util/DeviceStorageApp.kt | 32 ++++ .../main/java/be/mygod/reactmap/util/Utils.kt | 27 ++++ .../res/drawable/ic_notification_sync.xml | 5 + .../drawable/ic_notification_sync_problem.xml | 5 + app/src/main/res/values/colors.xml | 1 + gradle/libs.versions.toml | 4 +- 19 files changed, 661 insertions(+), 107 deletions(-) create mode 100644 app/src/main/java/be/mygod/reactmap/ConfigDialogFragment.kt create mode 100644 app/src/main/java/be/mygod/reactmap/ReactMapHttpEngine.kt create mode 100644 app/src/main/java/be/mygod/reactmap/follower/BackgroundLocationReceiver.kt create mode 100644 app/src/main/java/be/mygod/reactmap/follower/LastLocation.kt create mode 100644 app/src/main/java/be/mygod/reactmap/follower/LocationSetter.kt create mode 100644 app/src/main/java/be/mygod/reactmap/util/AlertDialogFragment.kt rename app/src/main/java/be/mygod/reactmap/{ => util}/CreateDynamicDocument.kt (95%) create mode 100644 app/src/main/java/be/mygod/reactmap/util/DeviceStorageApp.kt create mode 100644 app/src/main/java/be/mygod/reactmap/util/Utils.kt create mode 100644 app/src/main/res/drawable/ic_notification_sync.xml create mode 100644 app/src/main/res/drawable/ic_notification_sync_problem.xml diff --git a/app/build.gradle.kts b/app/build.gradle.kts index b898253..f7b0d52 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -3,6 +3,7 @@ plugins { alias(libs.plugins.firebaseCrashlytics) alias(libs.plugins.googleServices) alias(libs.plugins.kotlinAndroid) + id("kotlin-parcelize") } android { @@ -13,8 +14,8 @@ android { applicationId = "be.mygod.reactmap" minSdk = 24 targetSdk = 34 - versionCode = 16 - versionName = "0.4.5" + versionCode = 50 + versionName = "0.5.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } @@ -46,10 +47,11 @@ dependencies { implementation(libs.core.ktx) implementation(libs.firebase.analytics) implementation(libs.firebase.crashlytics) - implementation(libs.fragment) // update to 1.3+ to suppress lint + implementation(libs.fragment.ktx) implementation(libs.play.services.location) implementation(libs.lifecycle.common) implementation(libs.timber) + implementation(libs.work.ktx) testImplementation(libs.junit) androidTestImplementation(libs.androidx.test.ext.junit) androidTestImplementation(libs.espresso.core) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index a44128f..4f94e04 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -2,10 +2,12 @@ + + + + + + + + + + + + diff --git a/app/src/main/java/be/mygod/reactmap/App.kt b/app/src/main/java/be/mygod/reactmap/App.kt index 9cf4416..40b66a9 100644 --- a/app/src/main/java/be/mygod/reactmap/App.kt +++ b/app/src/main/java/be/mygod/reactmap/App.kt @@ -2,6 +2,9 @@ package be.mygod.reactmap import android.annotation.SuppressLint import android.app.Application +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager import android.content.Context import android.net.Uri import android.os.Build @@ -11,17 +14,44 @@ import android.webkit.WebView import android.widget.Toast import androidx.browser.customtabs.CustomTabColorSchemeParams import androidx.browser.customtabs.CustomTabsIntent +import androidx.core.content.getSystemService +import androidx.work.WorkManager +import be.mygod.reactmap.follower.BackgroundLocationReceiver +import be.mygod.reactmap.follower.LocationSetter +import be.mygod.reactmap.util.DeviceStorageApp +import com.google.android.gms.location.LocationServices import com.google.firebase.crashlytics.FirebaseCrashlytics +import com.google.firebase.ktx.Firebase +import com.google.firebase.ktx.initialize +import kotlinx.coroutines.DEBUG_PROPERTY_NAME +import kotlinx.coroutines.DEBUG_PROPERTY_VALUE_ON import timber.log.Timber class App : Application() { companion object { + private const val PREF_NAME = "reactmap" + const val KEY_ACTIVE_URL = "url.active" + const val URL_DEFAULT = "https://www.reactmap.dev" + lateinit var app: App } + lateinit var deviceStorage: Application + lateinit var work: WorkManager + val pref by lazy { deviceStorage.getSharedPreferences(PREF_NAME, MODE_PRIVATE) } + val fusedLocation by lazy { LocationServices.getFusedLocationProviderClient(deviceStorage) } + val nm by lazy { getSystemService()!! } + + val activeUrl get() = pref.getString(KEY_ACTIVE_URL, URL_DEFAULT) ?: URL_DEFAULT + override fun onCreate() { super.onCreate() app = this + deviceStorage = DeviceStorageApp(this) + deviceStorage.moveSharedPreferencesFrom(this, PREF_NAME) + // overhead of debug mode is minimal: https://github.com/Kotlin/kotlinx.coroutines/blob/f528898/docs/debugging.md#debug-mode + System.setProperty(DEBUG_PROPERTY_NAME, DEBUG_PROPERTY_VALUE_ON) + Firebase.initialize(deviceStorage) FirebaseCrashlytics.getInstance().apply { setCustomKey("build", Build.DISPLAY) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) setCustomKey("extension_s", @@ -43,6 +73,23 @@ class App : Application() { } }) if (BuildConfig.DEBUG) WebView.setWebContentsDebuggingEnabled(true) + + if (Build.VERSION.SDK_INT >= 26) nm.createNotificationChannels(listOf( + NotificationChannel(SiteController.CHANNEL_ID, "Full screen site controls", + NotificationManager.IMPORTANCE_LOW).apply { + lockscreenVisibility = Notification.VISIBILITY_SECRET + }, + NotificationChannel(LocationSetter.CHANNEL_ID, "Background location updating", + NotificationManager.IMPORTANCE_LOW).apply { + lockscreenVisibility = Notification.VISIBILITY_PUBLIC + }, + NotificationChannel(LocationSetter.CHANNEL_ID_ERROR, "Background location update failed", + NotificationManager.IMPORTANCE_HIGH).apply { + lockscreenVisibility = Notification.VISIBILITY_PUBLIC + }, + )) + work = WorkManager.getInstance(deviceStorage) + BackgroundLocationReceiver.setup() } private val customTabsIntent by lazy { diff --git a/app/src/main/java/be/mygod/reactmap/ConfigDialogFragment.kt b/app/src/main/java/be/mygod/reactmap/ConfigDialogFragment.kt new file mode 100644 index 0000000..eb531d8 --- /dev/null +++ b/app/src/main/java/be/mygod/reactmap/ConfigDialogFragment.kt @@ -0,0 +1,90 @@ +package be.mygod.reactmap + +import android.Manifest +import android.app.AlertDialog +import android.content.DialogInterface +import android.content.pm.PackageManager +import android.os.Parcelable +import android.widget.ArrayAdapter +import android.widget.AutoCompleteTextView +import android.widget.LinearLayout +import android.widget.Switch +import android.widget.Toast +import androidx.activity.result.contract.ActivityResultContracts +import androidx.core.content.edit +import androidx.core.net.toUri +import androidx.core.view.isGone +import be.mygod.reactmap.App.Companion.app +import be.mygod.reactmap.follower.BackgroundLocationReceiver +import be.mygod.reactmap.util.AlertDialogFragment +import be.mygod.reactmap.util.Empty +import kotlinx.parcelize.Parcelize + +class ConfigDialogFragment : AlertDialogFragment() { + companion object { + private const val KEY_HISTORY_URL = "url.history" + } + + @Parcelize + data class Ret(val hostname: String?) : Parcelable + + private lateinit var historyUrl: Set + private lateinit var urlEdit: AutoCompleteTextView + private lateinit var followerSwitch: Switch + + private val requestPermission = registerForActivityResult(ActivityResultContracts.RequestPermission()) { + if (!it) followerSwitch.isChecked = false + } + + override fun AlertDialog.Builder.prepare(listener: DialogInterface.OnClickListener) { + historyUrl = app.pref.getStringSet(KEY_HISTORY_URL, null) ?: setOf(App.URL_DEFAULT) + val context = requireContext() + urlEdit = AutoCompleteTextView(context).apply { + setAdapter(ArrayAdapter(context, android.R.layout.select_dialog_item, historyUrl.toTypedArray())) + setText(app.activeUrl) + } + followerSwitch = Switch(context).apply { + text = "Make alerts follow location in background" + isChecked = BackgroundLocationReceiver.enabled + isGone = true + setOnCheckedChangeListener { _, isChecked -> + if (isChecked) requestPermission.launch(Manifest.permission.ACCESS_BACKGROUND_LOCATION) + } + } + setView(LinearLayout(context).apply { + orientation = LinearLayout.VERTICAL + addView(urlEdit, LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT)) + addView(followerSwitch, LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, LinearLayout.LayoutParams.WRAP_CONTENT)) + }) + setTitle("ReactMap URL:") + setMessage("You can return to this dialog later by clicking on the notification.") + setPositiveButton(android.R.string.ok, listener) + setNegativeButton(android.R.string.cancel, null) + } + + override fun onResume() { + super.onResume() + val context = requireContext() + followerSwitch.isGone = context.checkSelfPermission(Manifest.permission.ACCESS_FINE_LOCATION) != + PackageManager.PERMISSION_GRANTED || + context.checkSelfPermission(Manifest.permission.ACCESS_COARSE_LOCATION) != + PackageManager.PERMISSION_GRANTED + } + + override val ret get() = Ret(try { + val (uri, host) = urlEdit.text!!.toString().toUri().let { + require("https".equals(it.scheme, true)) { "Only HTTPS is allowed" } + it.toString() to it.host!! + } + app.pref.edit { + putString(App.KEY_ACTIVE_URL, uri) + putStringSet(KEY_HISTORY_URL, historyUrl + uri) + } + host + } catch (e: Exception) { + Toast.makeText(requireContext(), e.localizedMessage ?: e.javaClass.name, Toast.LENGTH_LONG).show() + null + }).also { BackgroundLocationReceiver.enabled = followerSwitch.isChecked == true } +} diff --git a/app/src/main/java/be/mygod/reactmap/Glocation.kt b/app/src/main/java/be/mygod/reactmap/Glocation.kt index e5464cb..f95f1ce 100644 --- a/app/src/main/java/be/mygod/reactmap/Glocation.kt +++ b/app/src/main/java/be/mygod/reactmap/Glocation.kt @@ -14,11 +14,11 @@ import androidx.annotation.RequiresPermission import androidx.lifecycle.DefaultLifecycleObserver import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleOwner +import be.mygod.reactmap.App.Companion.app import com.google.android.gms.location.LocationAvailability import com.google.android.gms.location.LocationCallback import com.google.android.gms.location.LocationRequest import com.google.android.gms.location.LocationResult -import com.google.android.gms.location.LocationServices import com.google.android.gms.location.Priority import timber.log.Timber @@ -54,7 +54,6 @@ class Glocation(private val web: WebView) : DefaultLifecycleObserver { it.context as ComponentActivity }.also { it.lifecycle.addObserver(this) } private val jsSetup = activity.resources.openRawResource(R.raw.setup).bufferedReader().readText() - private val client = LocationServices.getFusedLocationProviderClient(activity) private val pendingRequests = mutableSetOf() private var pendingWatch = false private val activeListeners = mutableSetOf() @@ -119,7 +118,7 @@ class Glocation(private val web: WebView) : DefaultLifecycleObserver { private fun getCurrentPosition(granted: Boolean, ids: String) { @SuppressLint("MissingPermission") - if (granted) client.lastLocation.addOnCompleteListener { task -> + if (granted) app.fusedLocation.lastLocation.addOnCompleteListener { task -> val location = task.result web.evaluateJavascript(if (location == null) { "navigator.geolocation._getCurrentPositionError([$ids], ${task.exception.toGeolocationPositionError()})" @@ -165,12 +164,12 @@ class Glocation(private val web: WebView) : DefaultLifecycleObserver { @RequiresPermission(anyOf = [Manifest.permission.ACCESS_COARSE_LOCATION, Manifest.permission.ACCESS_FINE_LOCATION]) private fun requestLocationUpdates() { - client.requestLocationUpdates(LocationRequest.Builder(Priority.PRIORITY_LOW_POWER, 4000).apply { + app.fusedLocation.requestLocationUpdates(LocationRequest.Builder(Priority.PRIORITY_LOW_POWER, 4000).apply { // enableHighAccuracy - PRIORITY_HIGH_ACCURACY // expirationTime = timeout // maxWaitTime = maximumAge - setMinUpdateIntervalMillis(1000) setMinUpdateDistanceMeters(5f) + setMinUpdateIntervalMillis(1000) }.build(), callback, Looper.getMainLooper()).addOnCompleteListener { task -> if (task.isSuccessful) { Timber.d("Start watching location") @@ -180,8 +179,8 @@ class Glocation(private val web: WebView) : DefaultLifecycleObserver { ) } } - private fun removeLocationUpdates() = client.removeLocationUpdates(callback).addOnCompleteListener { task -> - if (task.isSuccessful) Timber.d("Stop watching location") - else Timber.w("Stop watch failed: ${task.exception}") + private fun removeLocationUpdates() = app.fusedLocation.removeLocationUpdates(callback).addOnCompleteListener { + if (it.isSuccessful) Timber.d("Stop watching location") + else Timber.w("Stop watch failed: ${it.exception}") } } diff --git a/app/src/main/java/be/mygod/reactmap/MainActivity.kt b/app/src/main/java/be/mygod/reactmap/MainActivity.kt index 59d897b..d746401 100644 --- a/app/src/main/java/be/mygod/reactmap/MainActivity.kt +++ b/app/src/main/java/be/mygod/reactmap/MainActivity.kt @@ -4,19 +4,13 @@ import android.annotation.SuppressLint import android.annotation.TargetApi import android.app.AlertDialog import android.content.Intent -import android.content.SharedPreferences import android.graphics.Bitmap import android.net.Uri -import android.net.http.ConnectionMigrationOptions -import android.net.http.HttpEngine -import android.os.Build import android.os.Bundle -import android.os.ext.SdkExtensions import android.util.JsonWriter import android.util.Log import android.view.WindowManager import android.webkit.ConsoleMessage -import android.webkit.CookieManager import android.webkit.RenderProcessGoneDetail import android.webkit.ValueCallback import android.webkit.WebChromeClient @@ -24,20 +18,20 @@ import android.webkit.WebResourceRequest import android.webkit.WebResourceResponse import android.webkit.WebView import android.webkit.WebViewClient -import android.widget.ArrayAdapter -import android.widget.AutoCompleteTextView import android.widget.Toast -import androidx.activity.ComponentActivity import androidx.activity.OnBackPressedCallback import androidx.activity.enableEdgeToEdge import androidx.activity.result.contract.ActivityResultContracts -import androidx.annotation.RequiresExtension import androidx.core.content.edit import androidx.core.net.toUri import androidx.core.os.bundleOf import androidx.core.view.WindowCompat +import androidx.fragment.app.FragmentActivity import androidx.lifecycle.lifecycleScope import be.mygod.reactmap.App.Companion.app +import be.mygod.reactmap.util.AlertDialogFragment +import be.mygod.reactmap.util.CreateDynamicDocument +import be.mygod.reactmap.util.findErrorStream import com.google.firebase.analytics.FirebaseAnalytics import kotlinx.coroutines.launch import org.json.JSONArray @@ -45,52 +39,28 @@ import org.json.JSONException import org.json.JSONObject import org.json.JSONTokener import timber.log.Timber -import java.io.File import java.io.IOException import java.io.Reader import java.io.StringWriter -import java.net.HttpURLConnection -import java.net.URL import java.net.URLDecoder import java.nio.charset.Charset import java.util.Locale -class MainActivity : ComponentActivity() { +class MainActivity : FragmentActivity() { companion object { const val ACTION_CONFIGURE = "be.mygod.reactmap.action.CONFIGURE" const val ACTION_RESTART_GAME = "be.mygod.reactmap.action.RESTART_GAME" - private const val PREF_NAME = "reactmap" private const val KEY_WELCOME = "welcome" - private const val KEY_ACTIVE_URL = "url.active" - private const val KEY_HISTORY_URL = "url.history" - private const val URL_DEFAULT = "https://www.reactmap.dev" private const val URL_RELOADING = "data:text/html;charset=utf-8,%3Ctitle%3ELoading...%3C%2Ftitle%3E%3Ch1%20style%3D%22display%3Aflex%3Bjustify-content%3Acenter%3Balign-items%3Acenter%3Btext-align%3Acenter%3Bheight%3A100vh%22%3ELoading..." private val filenameExtractor = "filename=(\"([^\"]+)\"|[^;]+)".toRegex(RegexOption.IGNORE_CASE) private val supportedHosts = setOf("discordapp.com", "discord.com") - - @get:RequiresExtension(Build.VERSION_CODES.S, 7) - private val engine by lazy @RequiresExtension(Build.VERSION_CODES.S, 7) { - val cache = File(app.cacheDir, "httpEngine") - HttpEngine.Builder(app).apply { - if (cache.mkdirs() || cache.isDirectory) { - setStoragePath(cache.absolutePath) - setEnableHttpCache(HttpEngine.Builder.HTTP_CACHE_DISK, 1024 * 1024) - } - setConnectionMigrationOptions(ConnectionMigrationOptions.Builder().apply { - setDefaultNetworkMigration(ConnectionMigrationOptions.MIGRATION_OPTION_ENABLED) - setPathDegradationMigration(ConnectionMigrationOptions.MIGRATION_OPTION_ENABLED) - }.build()) - setEnableBrotli(true) - }.build() - } } private lateinit var web: WebView private lateinit var glocation: Glocation private lateinit var siteController: SiteController - private lateinit var pref: SharedPreferences - private lateinit var hostname: String + lateinit var hostname: String private val windowInsetsController by lazy { WindowCompat.getInsetsController(window, web) } private var loginText: String? = null @@ -110,8 +80,7 @@ class MainActivity : ComponentActivity() { super.onCreate(savedInstanceState) window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) enableEdgeToEdge() - pref = getSharedPreferences(PREF_NAME, MODE_PRIVATE) - val activeUrl = pref.getString(KEY_ACTIVE_URL, URL_DEFAULT)!! + val activeUrl = app.activeUrl hostname = Uri.parse(activeUrl).host!! web = WebView(this).apply { settings.apply { @@ -176,7 +145,7 @@ class MainActivity : ComponentActivity() { override fun onPageFinished(view: WebView?, url: String) { if (url == URL_RELOADING) { - loadUrl(pref.getString(KEY_ACTIVE_URL, URL_DEFAULT)!!) + loadUrl(app.activeUrl) return } if (url.toUri().host == hostname) muiMargin.apply() @@ -216,7 +185,8 @@ class MainActivity : ComponentActivity() { FirebaseAnalytics.getInstance(this@MainActivity).logEvent("webviewExit", bundleOf("priority" to detail.rendererPriorityAtExit())) } - return false + finish() // WebView cannot be reused but keep the process alive if possible + return true } } setDownloadListener { url, _, contentDisposition, mimetype, _ -> @@ -232,23 +202,22 @@ class MainActivity : ComponentActivity() { loadUrl(activeUrl) } setContentView(web) - if (pref.getBoolean(KEY_WELCOME, true)) { + if (app.pref.getBoolean(KEY_WELCOME, true)) { startConfigure() - pref.edit { putBoolean(KEY_WELCOME, false) } + app.pref.edit { putBoolean(KEY_WELCOME, false) } + } + AlertDialogFragment.setResultListener(this) { _, ret -> + hostname = ret?.hostname ?: return@setResultListener + web.loadUrl(URL_RELOADING) } } private fun buildResponse(request: WebResourceRequest, transform: (Reader) -> String): WebResourceResponse { val url = request.url.toString() - val conn = (if (Build.VERSION.SDK_INT >= 34 || Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && - SdkExtensions.getExtensionVersion(Build.VERSION_CODES.S) >= 7) { - engine.openConnection(URL(url)) - } else URL(url).openConnection()) as HttpURLConnection - conn.requestMethod = request.method - for ((key, value) in request.requestHeaders) conn.addRequestProperty(key, value) - val cookie = CookieManager.getInstance() - cookie.getCookie(url)?.let { conn.addRequestProperty("Cookie", it) } - conn.headerFields["Set-Cookie"]?.forEach { cookie.setCookie(url, it) } + val conn = ReactMapHttpEngine.openConnection(url) { + requestMethod = request.method + for ((key, value) in request.requestHeaders) addRequestProperty(key, value) + } return WebResourceResponse(conn.contentType.split(';', limit = 2)[0], conn.contentEncoding, conn.responseCode, conn.responseMessage.let { if (it.isNullOrBlank()) "N/A" else it }, conn.headerFields.mapValues { (_, value) -> value.joinToString() }, @@ -260,7 +229,7 @@ class MainActivity : ComponentActivity() { } catch (e: IOException) { Timber.d(e) conn.inputStream - } else conn.errorStream ?: conn.inputStream) + } else conn.findErrorStream) } private fun handleSettings(request: WebResourceRequest) = buildResponse(request) { reader -> val response = reader.readText() @@ -308,35 +277,7 @@ class MainActivity : ComponentActivity() { }.show() } } - private fun startConfigure() = AlertDialog.Builder(this).apply { - val historyUrl = pref.getStringSet(KEY_HISTORY_URL, null) ?: setOf(URL_DEFAULT) - val editText = AutoCompleteTextView(this@MainActivity).apply { - setAdapter(ArrayAdapter(this@MainActivity, android.R.layout.select_dialog_item, - historyUrl.toTypedArray())) - setText(pref.getString(KEY_ACTIVE_URL, URL_DEFAULT)) - } - setView(editText) - setTitle("ReactMap URL:") - setMessage("You can return to this dialog later by clicking on the notification.") - setPositiveButton(android.R.string.ok) { _, _ -> - val (uri, host) = try { - editText.text!!.toString().toUri().run { - require("https".equals(scheme, true)) { "Only HTTPS is allowed" } - toString() to host!! - } - } catch (e: Exception) { - Toast.makeText(this@MainActivity, e.localizedMessage ?: e.javaClass.name, Toast.LENGTH_LONG).show() - return@setPositiveButton - } - pref.edit { - putString(KEY_ACTIVE_URL, uri) - putStringSet(KEY_HISTORY_URL, historyUrl + uri) - } - hostname = host - web.loadUrl(URL_RELOADING) - } - setNegativeButton(android.R.string.cancel, null) - }.show() + private fun startConfigure() = ConfigDialogFragment().apply { key() }.show(supportFragmentManager, null) private fun restartGame(packageName: String) { try { ProcessBuilder("su", "-c", "am force-stop $packageName &&" + diff --git a/app/src/main/java/be/mygod/reactmap/ReactMapHttpEngine.kt b/app/src/main/java/be/mygod/reactmap/ReactMapHttpEngine.kt new file mode 100644 index 0000000..3cf485a --- /dev/null +++ b/app/src/main/java/be/mygod/reactmap/ReactMapHttpEngine.kt @@ -0,0 +1,41 @@ +package be.mygod.reactmap + +import android.net.http.ConnectionMigrationOptions +import android.net.http.HttpEngine +import android.os.Build +import android.os.ext.SdkExtensions +import android.webkit.CookieManager +import androidx.annotation.RequiresExtension +import be.mygod.reactmap.App.Companion.app +import java.io.File +import java.net.HttpURLConnection +import java.net.URL + +object ReactMapHttpEngine { + @get:RequiresExtension(Build.VERSION_CODES.S, 7) + private val engine by lazy @RequiresExtension(Build.VERSION_CODES.S, 7) { + val cache = File(app.deviceStorage.cacheDir, "httpEngine") + HttpEngine.Builder(app.deviceStorage).apply { + if (cache.mkdirs() || cache.isDirectory) { + setStoragePath(cache.absolutePath) + setEnableHttpCache(HttpEngine.Builder.HTTP_CACHE_DISK, 1024 * 1024) + } + setConnectionMigrationOptions(ConnectionMigrationOptions.Builder().apply { + setDefaultNetworkMigration(ConnectionMigrationOptions.MIGRATION_OPTION_ENABLED) + setPathDegradationMigration(ConnectionMigrationOptions.MIGRATION_OPTION_ENABLED) + }.build()) + setEnableBrotli(true) + }.build() + } + + fun openConnection(url: String, setup: HttpURLConnection.() -> Unit) = ((if (Build.VERSION.SDK_INT >= 34 || + Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && + SdkExtensions.getExtensionVersion(Build.VERSION_CODES.S) >= 7) { + engine.openConnection(URL(url)) + } else URL(url).openConnection()) as HttpURLConnection).apply { + val cookie = CookieManager.getInstance() + cookie.getCookie(url)?.let { addRequestProperty("Cookie", it) } + setup() + headerFields["Set-Cookie"]?.forEach { cookie.setCookie(url, it) } + } +} diff --git a/app/src/main/java/be/mygod/reactmap/SiteController.kt b/app/src/main/java/be/mygod/reactmap/SiteController.kt index 96e51aa..0c86826 100644 --- a/app/src/main/java/be/mygod/reactmap/SiteController.kt +++ b/app/src/main/java/be/mygod/reactmap/SiteController.kt @@ -1,32 +1,24 @@ package be.mygod.reactmap -import android.app.NotificationChannel -import android.app.NotificationManager import android.app.PendingIntent import android.content.Intent -import android.os.Build import androidx.activity.ComponentActivity import androidx.activity.result.contract.ActivityResultContracts import androidx.core.app.NotificationCompat -import androidx.core.content.getSystemService import androidx.lifecycle.DefaultLifecycleObserver import androidx.lifecycle.LifecycleOwner +import be.mygod.reactmap.App.Companion.app class SiteController(private val activity: ComponentActivity) : DefaultLifecycleObserver { companion object { - private const val CHANNEL_ID = "control" + const val CHANNEL_ID = "control" } - private val nm = activity.getSystemService()!! init { activity.lifecycle.addObserver(this) - if (Build.VERSION.SDK_INT >= 26) nm.createNotificationChannel( - NotificationChannel(CHANNEL_ID, "Full screen site controls", NotificationManager.IMPORTANCE_LOW).apply { - lockscreenVisibility = NotificationCompat.VISIBILITY_SECRET - }) } private val requestPermission = activity.registerForActivityResult(ActivityResultContracts.RequestPermission()) { - if (it && started) nm.notify(1, NotificationCompat.Builder(activity, CHANNEL_ID).apply { + if (it && started) app.nm.notify(1, NotificationCompat.Builder(activity, CHANNEL_ID).apply { setWhen(0) color = activity.getColor(R.color.main_blue) setCategory(NotificationCompat.CATEGORY_SERVICE) @@ -39,7 +31,7 @@ class SiteController(private val activity: ComponentActivity) : DefaultLifecycle setContentIntent(PendingIntent.getActivity(activity, 0, Intent(activity, MainActivity::class.java).setAction(MainActivity.ACTION_CONFIGURE), PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)) - addAction(android.R.drawable.ic_delete, "Restart game", PendingIntent.getActivity(activity, + addAction(R.drawable.ic_notification_sync, "Restart game", PendingIntent.getActivity(activity, 1, Intent(activity, MainActivity::class.java).setAction(MainActivity.ACTION_RESTART_GAME), PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)) }.build()) @@ -59,6 +51,6 @@ class SiteController(private val activity: ComponentActivity) : DefaultLifecycle override fun onStop(owner: LifecycleOwner) { started = false - nm.cancel(1) + app.nm.cancel(1) } } diff --git a/app/src/main/java/be/mygod/reactmap/follower/BackgroundLocationReceiver.kt b/app/src/main/java/be/mygod/reactmap/follower/BackgroundLocationReceiver.kt new file mode 100644 index 0000000..69c86c2 --- /dev/null +++ b/app/src/main/java/be/mygod/reactmap/follower/BackgroundLocationReceiver.kt @@ -0,0 +1,149 @@ +package be.mygod.reactmap.follower + +import android.Manifest +import android.app.PendingIntent +import android.content.BroadcastReceiver +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.location.Location +import androidx.annotation.MainThread +import androidx.work.BackoffPolicy +import androidx.work.Constraints +import androidx.work.ExistingWorkPolicy +import androidx.work.NetworkType +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.OutOfQuotaPolicy +import androidx.work.WorkRequest +import androidx.work.workDataOf +import be.mygod.reactmap.App.Companion.app +import be.mygod.reactmap.util.toByteArray +import be.mygod.reactmap.util.toParcelable +import com.google.android.gms.location.LocationRequest +import com.google.android.gms.location.LocationResult +import com.google.android.gms.location.Priority +import timber.log.Timber +import java.io.File +import java.io.FileNotFoundException +import java.util.concurrent.TimeUnit + +class BackgroundLocationReceiver : BroadcastReceiver() { + companion object { +// private const val KEY = "backgroundLocation.enabled" + private const val ACTION_LOCATION = "location" + const val MIN_UPDATE_THRESHOLD_METER = 40f + + private val componentName by lazy { ComponentName(app, BackgroundLocationReceiver::class.java) } + var enabled: Boolean + get() = app.packageManager.getComponentEnabledSetting(componentName) == + PackageManager.COMPONENT_ENABLED_STATE_ENABLED + set(value) { + app.packageManager.setComponentEnabledSetting(componentName, if (value) { + PackageManager.COMPONENT_ENABLED_STATE_ENABLED + } else PackageManager.COMPONENT_ENABLED_STATE_DISABLED, PackageManager.DONT_KILL_APP) + if (value) setup() else stop() + } +// private val userEnabled get() = app.pref.getBoolean(KEY, true) + + private val locationPendingIntent by lazy { + PendingIntent.getBroadcast(app, 0, Intent(app, BackgroundLocationReceiver::class.java).setAction( + ACTION_LOCATION + ), PendingIntent.FLAG_MUTABLE or PendingIntent.FLAG_CANCEL_CURRENT) + } + private val persistedLastLocationFile = File(app.deviceStorage.noBackupFilesDir, "lastLocation") + var persistedLastLocation: LastLocation? + get() = try { + persistedLastLocationFile.readBytes().toParcelable() + } catch (_: FileNotFoundException) { + null + } catch (e: Exception) { + Timber.w(e) + null + } + set(value) { + try { + if (value != null) { + persistedLastLocationFile.writeBytes(value.toByteArray()) + } else if (!persistedLastLocationFile.delete()) persistedLastLocationFile.deleteOnExit() + } catch (e: Exception) { + Timber.w(e) + } + } + + private var active = false + fun setup() { + if (active || !enabled) return + if (app.checkSelfPermission(Manifest.permission.ACCESS_FINE_LOCATION) != + PackageManager.PERMISSION_GRANTED && + app.checkSelfPermission(Manifest.permission.ACCESS_COARSE_LOCATION) != + PackageManager.PERMISSION_GRANTED || + app.checkSelfPermission(Manifest.permission.ACCESS_BACKGROUND_LOCATION) != + PackageManager.PERMISSION_GRANTED) { + LocationSetter.notifyError("Background location permission missing") + return + } + app.fusedLocation.requestLocationUpdates(LocationRequest.Builder(Priority.PRIORITY_PASSIVE, + 5 * 60 * 1000).apply { + setMaxUpdateAgeMillis(0) + setMinUpdateDistanceMeters(MIN_UPDATE_THRESHOLD_METER) + setMinUpdateIntervalMillis(60 * 1000) + }.build(), locationPendingIntent).addOnCompleteListener { task -> + if (task.isSuccessful) active = true else Timber.w(task.exception) + } + } + private fun stop() { + if (active) app.fusedLocation.removeLocationUpdates(locationPendingIntent).addOnCompleteListener { task -> + if (task.isSuccessful) active = false else Timber.w(task.exception) + } + } + + @MainThread + fun onLocationSubmitted(location: Location) { + persistedLastLocation = LastLocation(persistedLastLocation?.location ?: location, location) + } + } + + override fun onReceive(context: Context?, intent: Intent) { + when (intent.action) { + Intent.ACTION_BOOT_COMPLETED, Intent.ACTION_LOCKED_BOOT_COMPLETED, Intent.ACTION_MY_PACKAGE_REPLACED -> { + // already handled during App init + } + ACTION_LOCATION -> { + val locations = LocationResult.extractResult(intent)?.locations + if (locations.isNullOrEmpty()) return + val lastLocation = persistedLastLocation + var bestLocation = lastLocation?.location + for (location in locations) { + if (bestLocation != null) { + if (bestLocation.time > location.time) continue + if (bestLocation.hasAccuracy()) { + if (!location.hasAccuracy()) continue + // keep the old more accurate location if new estimate does not contradict old estimate + if (bestLocation.accuracy < location.accuracy && + location.distanceTo(bestLocation) <= bestLocation.accuracy + location.accuracy) continue + } + } + bestLocation = location + } + if (bestLocation!! == lastLocation?.location) return + val shouldSet = lastLocation?.submittedLocation + ?.run { distanceTo(bestLocation) < MIN_UPDATE_THRESHOLD_METER } != true + Timber.d("Updating $lastLocation -> $bestLocation (submitting $shouldSet)") + persistedLastLocation = LastLocation(bestLocation, lastLocation?.submittedLocation) + if (shouldSet) app.work.enqueueUniqueWork("LocationSetter", ExistingWorkPolicy.REPLACE, + OneTimeWorkRequestBuilder().apply { + setBackoffCriteria(BackoffPolicy.LINEAR, WorkRequest.MIN_BACKOFF_MILLIS, TimeUnit.MILLISECONDS) + setConstraints(Constraints.Builder().apply { + setRequiredNetworkType(NetworkType.CONNECTED) + // Expedited jobs only support network and storage constraints +// setRequiresBatteryNotLow(true) + }.build()) + setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST) + setInputData(workDataOf(LocationSetter.KEY_LATITUDE to bestLocation.latitude, + LocationSetter.KEY_LONGITUDE to bestLocation.longitude)) + }.build()) + } + } + } +} diff --git a/app/src/main/java/be/mygod/reactmap/follower/LastLocation.kt b/app/src/main/java/be/mygod/reactmap/follower/LastLocation.kt new file mode 100644 index 0000000..ca4a005 --- /dev/null +++ b/app/src/main/java/be/mygod/reactmap/follower/LastLocation.kt @@ -0,0 +1,11 @@ +package be.mygod.reactmap.follower + +import android.location.Location +import android.os.Parcelable +import kotlinx.parcelize.Parcelize + +@Parcelize +data class LastLocation( + val location: Location, + val submittedLocation: Location? = null, +) : Parcelable diff --git a/app/src/main/java/be/mygod/reactmap/follower/LocationSetter.kt b/app/src/main/java/be/mygod/reactmap/follower/LocationSetter.kt new file mode 100644 index 0000000..2935682 --- /dev/null +++ b/app/src/main/java/be/mygod/reactmap/follower/LocationSetter.kt @@ -0,0 +1,117 @@ +package be.mygod.reactmap.follower + +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.content.pm.ServiceInfo +import android.location.Location +import androidx.core.app.NotificationCompat +import androidx.core.net.toUri +import androidx.work.CoroutineWorker +import androidx.work.ForegroundInfo +import androidx.work.WorkerParameters +import be.mygod.reactmap.App.Companion.app +import be.mygod.reactmap.MainActivity +import be.mygod.reactmap.R +import be.mygod.reactmap.ReactMapHttpEngine +import be.mygod.reactmap.util.findErrorStream +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.json.JSONArray +import org.json.JSONObject +import timber.log.Timber +import java.io.IOException + +class LocationSetter(appContext: Context, workerParams: WorkerParameters) : CoroutineWorker(appContext, workerParams) { + companion object { + const val CHANNEL_ID = "locationSetter" + const val CHANNEL_ID_ERROR = "locationSetterError" + const val KEY_LATITUDE = "latitude" + const val KEY_LONGITUDE = "longitude" + + fun notifyError(message: CharSequence) { + app.nm.notify(3, NotificationCompat.Builder(app, CHANNEL_ID_ERROR).apply { + setWhen(0) + color = app.getColor(R.color.main_orange) + setCategory(NotificationCompat.CATEGORY_ALARM) + setContentTitle("Failed to update location") + setContentText(message) + setVisibility(NotificationCompat.VISIBILITY_PUBLIC) + setSmallIcon(R.drawable.ic_notification_sync_problem) + priority = NotificationCompat.PRIORITY_MAX + setContentIntent(PendingIntent.getActivity(app, 2, + Intent(app, MainActivity::class.java).setAction(MainActivity.ACTION_CONFIGURE), + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)) + }.build()) + } + } + + override suspend fun doWork() = try { + val lat = inputData.getDouble(KEY_LATITUDE, Double.NaN) + val lon = inputData.getDouble(KEY_LONGITUDE, Double.NaN) + val conn = ReactMapHttpEngine.openConnection(app.activeUrl.toUri().buildUpon().apply { + path("/graphql") + }.build().toString()) { + doOutput = true + requestMethod = "POST" + addRequestProperty("Content-Type", "application/json") + outputStream.bufferedWriter().use { + it.write(JSONObject().apply { + put("operationName", "Webhook") + put("variables", JSONObject().apply { + put("category", "setLocation") + put("data", JSONArray(arrayOf(lat, lon))) + put("status", "POST") + }) + // epic graphql query yay >:( + put("query", "mutation Webhook(\$data: JSON, \$category: String!, \$status: String!) {" + + "webhook(data: \$data, category: \$category, status: \$status) { __typename } }") + }.toString()) + } + } + when (val code = conn.responseCode) { + 200 -> { + withContext(Dispatchers.Main) { + BackgroundLocationReceiver.onLocationSubmitted(Location("").apply { + latitude = lat + longitude = lon + }) + } + Result.success() + } + else -> { + val error = conn.findErrorStream.bufferedReader().readText() + Timber.w(Exception(error)) + val json = JSONObject(error).getJSONArray("errors") + notifyError((0 until json.length()).joinToString { json.getJSONObject(it).getString("message") }) + if (code == 401 || code == 511) { + // TODO: handle 511 session expired + Result.failure() + } else Result.retry() + } + } + } catch (e: IOException) { + Timber.d(e) + Result.retry() + } catch (e: Exception) { + Timber.w(e) + notifyError(e.localizedMessage ?: e.javaClass.name) + Result.failure() + } + + override suspend fun getForegroundInfo() = ForegroundInfo(2, NotificationCompat.Builder(app, CHANNEL_ID).apply { + setWhen(0) + color = app.getColor(R.color.main_blue) + setCategory(NotificationCompat.CATEGORY_SERVICE) + setContentTitle("Updating location") + setContentText("${inputData.getDouble(KEY_LATITUDE, Double.NaN)}, " + + inputData.getDouble(KEY_LONGITUDE, Double.NaN)) + setVisibility(NotificationCompat.VISIBILITY_PUBLIC) + setSmallIcon(R.drawable.ic_notification_sync) + setProgress(0, 0, true) + priority = NotificationCompat.PRIORITY_LOW + setContentIntent(PendingIntent.getActivity(app, 2, + Intent(app, MainActivity::class.java).setAction(MainActivity.ACTION_CONFIGURE), + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)) + }.build(), ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC) +} diff --git a/app/src/main/java/be/mygod/reactmap/util/AlertDialogFragment.kt b/app/src/main/java/be/mygod/reactmap/util/AlertDialogFragment.kt new file mode 100644 index 0000000..1b6db17 --- /dev/null +++ b/app/src/main/java/be/mygod/reactmap/util/AlertDialogFragment.kt @@ -0,0 +1,73 @@ +package be.mygod.reactmap.util + +import android.app.Activity +import android.app.AlertDialog +import android.content.DialogInterface +import android.os.Bundle +import android.os.Parcelable +import androidx.core.os.bundleOf +import androidx.fragment.app.DialogFragment +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentActivity +import androidx.fragment.app.setFragmentResult +import androidx.fragment.app.setFragmentResultListener +import kotlinx.parcelize.Parcelize + +/** + * Based on: https://android.googlesource.com/platform/packages/apps/ExactCalculator/+/8c43f06/src/com/android/calculator2/AlertDialogFragment.java + */ +abstract class AlertDialogFragment : + DialogFragment(), DialogInterface.OnClickListener { + companion object { + private const val KEY_RESULT = "result" + private const val KEY_ARG = "arg" + private const val KEY_RET = "ret" + private const val KEY_WHICH = "which" + + fun setResultListener(activity: FragmentActivity, requestKey: String, + listener: (Int, Ret?) -> Unit) { + activity.supportFragmentManager.setFragmentResultListener(requestKey, activity) { _, bundle -> + listener(bundle.getInt(KEY_WHICH, Activity.RESULT_CANCELED), bundle.getParcelable(KEY_RET)) + } + } + fun setResultListener(fragment: Fragment, requestKey: String, + listener: (Int, Ret?) -> Unit) { + fragment.setFragmentResultListener(requestKey) { _, bundle -> + listener(bundle.getInt(KEY_WHICH, Activity.RESULT_CANCELED), bundle.getParcelable(KEY_RET)) + } + } + inline fun , Ret : Parcelable> setResultListener( + activity: FragmentActivity, noinline listener: (Int, Ret?) -> Unit) = + setResultListener(activity, T::class.java.name, listener) + inline fun , Ret : Parcelable> setResultListener( + fragment: Fragment, noinline listener: (Int, Ret?) -> Unit) = + setResultListener(fragment, T::class.java.name, listener) + } + protected abstract fun AlertDialog.Builder.prepare(listener: DialogInterface.OnClickListener) + + private val resultKey get() = requireArguments().getString(KEY_RESULT) + protected val arg by lazy { requireArguments().getParcelable(KEY_ARG)!! } + protected open val ret: Ret? get() = null + + private fun args() = arguments ?: Bundle().also { arguments = it } + fun arg(arg: Arg) = args().putParcelable(KEY_ARG, arg) + fun key(resultKey: String = javaClass.name) = args().putString(KEY_RESULT, resultKey) + + override fun onCreateDialog(savedInstanceState: Bundle?): AlertDialog = + AlertDialog.Builder(requireContext()).also { it.prepare(this) }.create() + + override fun onClick(dialog: DialogInterface?, which: Int) { + setFragmentResult(resultKey ?: return, Bundle().apply { + putInt(KEY_WHICH, which) + putParcelable(KEY_RET, ret ?: return@apply) + }) + } + + override fun onDismiss(dialog: DialogInterface) { + super.onDismiss(dialog) + setFragmentResult(resultKey ?: return, bundleOf(KEY_WHICH to Activity.RESULT_CANCELED)) + } +} + +@Parcelize +class Empty : Parcelable diff --git a/app/src/main/java/be/mygod/reactmap/CreateDynamicDocument.kt b/app/src/main/java/be/mygod/reactmap/util/CreateDynamicDocument.kt similarity index 95% rename from app/src/main/java/be/mygod/reactmap/CreateDynamicDocument.kt rename to app/src/main/java/be/mygod/reactmap/util/CreateDynamicDocument.kt index 34ce6fc..e215afe 100644 --- a/app/src/main/java/be/mygod/reactmap/CreateDynamicDocument.kt +++ b/app/src/main/java/be/mygod/reactmap/util/CreateDynamicDocument.kt @@ -1,4 +1,4 @@ -package be.mygod.reactmap +package be.mygod.reactmap.util import android.app.Activity import android.content.Context diff --git a/app/src/main/java/be/mygod/reactmap/util/DeviceStorageApp.kt b/app/src/main/java/be/mygod/reactmap/util/DeviceStorageApp.kt new file mode 100644 index 0000000..783cd94 --- /dev/null +++ b/app/src/main/java/be/mygod/reactmap/util/DeviceStorageApp.kt @@ -0,0 +1,32 @@ +package be.mygod.reactmap.util + +import android.app.Application +import android.content.Context +import android.util.Log +import androidx.work.Configuration +import be.mygod.reactmap.BuildConfig +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.launch + +class DeviceStorageApp(context: Context) : Application(), Configuration.Provider { + init { + attachBaseContext(context.createDeviceProtectedStorageContext()) + } + + /** + * Thou shalt not get the REAL underlying application context which would no longer be operating under device + * protected storage. + */ + override fun getApplicationContext(): Context = this + + /** + * Fuck you androidx.work. + */ + override fun isDeviceProtectedStorage() = false + + override fun getWorkManagerConfiguration() = Configuration.Builder().apply { + setExecutor { GlobalScope.launch(Dispatchers.IO) { it.run() } } + if (BuildConfig.DEBUG) setMinimumLoggingLevel(Log.VERBOSE) + }.build() +} diff --git a/app/src/main/java/be/mygod/reactmap/util/Utils.kt b/app/src/main/java/be/mygod/reactmap/util/Utils.kt new file mode 100644 index 0000000..44d210c --- /dev/null +++ b/app/src/main/java/be/mygod/reactmap/util/Utils.kt @@ -0,0 +1,27 @@ +package be.mygod.reactmap.util + +import android.os.Parcel +import android.os.Parcelable +import androidx.core.os.ParcelCompat +import java.net.HttpURLConnection + +inline fun useParcel(block: (Parcel) -> T) = Parcel.obtain().run { + try { + block(this) + } finally { + recycle() + } +} + +fun Parcelable?.toByteArray(parcelableFlags: Int = 0) = useParcel { p -> + p.writeParcelable(this, parcelableFlags) + p.marshall() +} +inline fun ByteArray.toParcelable(classLoader: ClassLoader? = T::class.java.classLoader) = + useParcel { p -> + p.unmarshall(this, 0, size) + p.setDataPosition(0) + ParcelCompat.readParcelable(p, classLoader, T::class.java) + } + +val HttpURLConnection.findErrorStream get() = errorStream ?: inputStream diff --git a/app/src/main/res/drawable/ic_notification_sync.xml b/app/src/main/res/drawable/ic_notification_sync.xml new file mode 100644 index 0000000..6c2e2b7 --- /dev/null +++ b/app/src/main/res/drawable/ic_notification_sync.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/ic_notification_sync_problem.xml b/app/src/main/res/drawable/ic_notification_sync_problem.xml new file mode 100644 index 0000000..4e48a54 --- /dev/null +++ b/app/src/main/res/drawable/ic_notification_sync_problem.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index 4fb8944..5529ba5 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -2,4 +2,5 @@ #0D0D0D #2596BE + #ffa133 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 317161f..f822898 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -15,6 +15,7 @@ lifecycle = "2.6.2" google-services = "4.4.0" play-services-location = "21.0.1" timber = "5.0.1" +work = "2.8.1" gradle-versions = "0.48.0" [libraries] @@ -27,11 +28,12 @@ espresso-core = { group = "androidx.test.espresso", name = "espresso-core", vers firebase-analytics = { group = "com.google.firebase", name = "firebase-analytics" } firebase-bom = { group = "com.google.firebase", name = "firebase-bom", version.ref = "firebase" } firebase-crashlytics = { group = "com.google.firebase", name = "firebase-crashlytics" } -fragment = { group = "androidx.fragment", name = "fragment", version.ref = "fragment" } +fragment-ktx = { group = "androidx.fragment", name = "fragment-ktx", version.ref = "fragment" } junit = { group = "junit", name = "junit", version.ref = "junit" } lifecycle-common = { group = "androidx.lifecycle", name = "lifecycle-common", version.ref = "lifecycle" } play-services-location = { group = "com.google.android.gms", name = "play-services-location", version.ref = "play-services-location" } timber = { group = "com.jakewharton.timber", name = "timber", version.ref = "timber" } +work-ktx = { group = "androidx.work", name = "work-runtime-ktx", version.ref = "work" } [plugins] androidApplication = { id = "com.android.application", version.ref = "agp" } From ca3f35e3922667497d79e08a15c355d9bd3e354b Mon Sep 17 00:00:00 2001 From: Mygod Date: Mon, 18 Sep 2023 19:31:17 -0400 Subject: [PATCH 02/26] Fix lint --- app/src/main/AndroidManifest.xml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 4f94e04..263b7ae 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -44,6 +44,10 @@ + From 00da974406b60336002851b0d9003a0a9693b35d Mon Sep 17 00:00:00 2001 From: Mygod Date: Mon, 18 Sep 2023 19:44:33 -0400 Subject: [PATCH 03/26] Handle 401/511 errors --- app/src/main/java/be/mygod/reactmap/MainActivity.kt | 5 ++++- .../be/mygod/reactmap/follower/BackgroundLocationReceiver.kt | 5 ++++- .../main/java/be/mygod/reactmap/follower/LocationSetter.kt | 2 +- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/be/mygod/reactmap/MainActivity.kt b/app/src/main/java/be/mygod/reactmap/MainActivity.kt index d746401..5076da7 100644 --- a/app/src/main/java/be/mygod/reactmap/MainActivity.kt +++ b/app/src/main/java/be/mygod/reactmap/MainActivity.kt @@ -29,6 +29,7 @@ import androidx.core.view.WindowCompat import androidx.fragment.app.FragmentActivity import androidx.lifecycle.lifecycleScope import be.mygod.reactmap.App.Companion.app +import be.mygod.reactmap.follower.BackgroundLocationReceiver import be.mygod.reactmap.util.AlertDialogFragment import be.mygod.reactmap.util.CreateDynamicDocument import be.mygod.reactmap.util.findErrorStream @@ -148,7 +149,9 @@ class MainActivity : FragmentActivity() { loadUrl(app.activeUrl) return } - if (url.toUri().host == hostname) muiMargin.apply() + if (url.toUri().host != hostname) return + muiMargin.apply() + BackgroundLocationReceiver.setup() // redo setup in case cookie is updated } override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean { diff --git a/app/src/main/java/be/mygod/reactmap/follower/BackgroundLocationReceiver.kt b/app/src/main/java/be/mygod/reactmap/follower/BackgroundLocationReceiver.kt index 69c86c2..a386ce1 100644 --- a/app/src/main/java/be/mygod/reactmap/follower/BackgroundLocationReceiver.kt +++ b/app/src/main/java/be/mygod/reactmap/follower/BackgroundLocationReceiver.kt @@ -35,6 +35,7 @@ class BackgroundLocationReceiver : BroadcastReceiver() { const val MIN_UPDATE_THRESHOLD_METER = 40f private val componentName by lazy { ComponentName(app, BackgroundLocationReceiver::class.java) } + @get:MainThread @set:MainThread var enabled: Boolean get() = app.packageManager.getComponentEnabledSetting(componentName) == PackageManager.COMPONENT_ENABLED_STATE_ENABLED @@ -72,6 +73,7 @@ class BackgroundLocationReceiver : BroadcastReceiver() { } private var active = false + @MainThread fun setup() { if (active || !enabled) return if (app.checkSelfPermission(Manifest.permission.ACCESS_FINE_LOCATION) != @@ -92,7 +94,8 @@ class BackgroundLocationReceiver : BroadcastReceiver() { if (task.isSuccessful) active = true else Timber.w(task.exception) } } - private fun stop() { + @MainThread + fun stop() { if (active) app.fusedLocation.removeLocationUpdates(locationPendingIntent).addOnCompleteListener { task -> if (task.isSuccessful) active = false else Timber.w(task.exception) } diff --git a/app/src/main/java/be/mygod/reactmap/follower/LocationSetter.kt b/app/src/main/java/be/mygod/reactmap/follower/LocationSetter.kt index 2935682..4fb143c 100644 --- a/app/src/main/java/be/mygod/reactmap/follower/LocationSetter.kt +++ b/app/src/main/java/be/mygod/reactmap/follower/LocationSetter.kt @@ -85,7 +85,7 @@ class LocationSetter(appContext: Context, workerParams: WorkerParameters) : Coro val json = JSONObject(error).getJSONArray("errors") notifyError((0 until json.length()).joinToString { json.getJSONObject(it).getString("message") }) if (code == 401 || code == 511) { - // TODO: handle 511 session expired + withContext(Dispatchers.Main) { BackgroundLocationReceiver.stop() } Result.failure() } else Result.retry() } From 7684b00c064ae20b8bc812590d1d39b0f70d6b4b Mon Sep 17 00:00:00 2001 From: Mygod Date: Mon, 18 Sep 2023 20:48:28 -0400 Subject: [PATCH 04/26] Defer setting debug flag until safe --- app/src/main/java/be/mygod/reactmap/App.kt | 2 -- app/src/main/java/be/mygod/reactmap/MainActivity.kt | 1 + 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/app/src/main/java/be/mygod/reactmap/App.kt b/app/src/main/java/be/mygod/reactmap/App.kt index 40b66a9..d0290ef 100644 --- a/app/src/main/java/be/mygod/reactmap/App.kt +++ b/app/src/main/java/be/mygod/reactmap/App.kt @@ -10,7 +10,6 @@ import android.net.Uri import android.os.Build import android.os.ext.SdkExtensions import android.util.Log -import android.webkit.WebView import android.widget.Toast import androidx.browser.customtabs.CustomTabColorSchemeParams import androidx.browser.customtabs.CustomTabsIntent @@ -72,7 +71,6 @@ class App : Application() { } } }) - if (BuildConfig.DEBUG) WebView.setWebContentsDebuggingEnabled(true) if (Build.VERSION.SDK_INT >= 26) nm.createNotificationChannels(listOf( NotificationChannel(SiteController.CHANNEL_ID, "Full screen site controls", diff --git a/app/src/main/java/be/mygod/reactmap/MainActivity.kt b/app/src/main/java/be/mygod/reactmap/MainActivity.kt index 5076da7..85bbe90 100644 --- a/app/src/main/java/be/mygod/reactmap/MainActivity.kt +++ b/app/src/main/java/be/mygod/reactmap/MainActivity.kt @@ -81,6 +81,7 @@ class MainActivity : FragmentActivity() { super.onCreate(savedInstanceState) window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) enableEdgeToEdge() + if (BuildConfig.DEBUG) WebView.setWebContentsDebuggingEnabled(true) val activeUrl = app.activeUrl hostname = Uri.parse(activeUrl).host!! web = WebView(this).apply { From 35468df18bcf2738eefd8e0f2cfd86815548f7f9 Mon Sep 17 00:00:00 2001 From: Mygod Date: Mon, 18 Sep 2023 21:00:13 -0400 Subject: [PATCH 05/26] Add notification for successful upload of location --- app/src/main/java/be/mygod/reactmap/App.kt | 4 ++++ .../java/be/mygod/reactmap/SiteController.kt | 1 + .../mygod/reactmap/follower/LocationSetter.kt | 23 +++++++++++++++---- 3 files changed, 24 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/be/mygod/reactmap/App.kt b/app/src/main/java/be/mygod/reactmap/App.kt index d0290ef..bd9d632 100644 --- a/app/src/main/java/be/mygod/reactmap/App.kt +++ b/app/src/main/java/be/mygod/reactmap/App.kt @@ -81,6 +81,10 @@ class App : Application() { NotificationManager.IMPORTANCE_LOW).apply { lockscreenVisibility = Notification.VISIBILITY_PUBLIC }, + NotificationChannel(LocationSetter.CHANNEL_ID_SUCCESS, "Background location updated", + NotificationManager.IMPORTANCE_MIN).apply { + lockscreenVisibility = Notification.VISIBILITY_PUBLIC + }, NotificationChannel(LocationSetter.CHANNEL_ID_ERROR, "Background location update failed", NotificationManager.IMPORTANCE_HIGH).apply { lockscreenVisibility = Notification.VISIBILITY_PUBLIC diff --git a/app/src/main/java/be/mygod/reactmap/SiteController.kt b/app/src/main/java/be/mygod/reactmap/SiteController.kt index 0c86826..3415dda 100644 --- a/app/src/main/java/be/mygod/reactmap/SiteController.kt +++ b/app/src/main/java/be/mygod/reactmap/SiteController.kt @@ -24,6 +24,7 @@ class SiteController(private val activity: ComponentActivity) : DefaultLifecycle setCategory(NotificationCompat.CATEGORY_SERVICE) setContentTitle(title) setContentText("Tap to configure") + setGroup(CHANNEL_ID) setSmallIcon(R.drawable.ic_reactmap) setOngoing(true) priority = NotificationCompat.PRIORITY_LOW diff --git a/app/src/main/java/be/mygod/reactmap/follower/LocationSetter.kt b/app/src/main/java/be/mygod/reactmap/follower/LocationSetter.kt index 4fb143c..8555971 100644 --- a/app/src/main/java/be/mygod/reactmap/follower/LocationSetter.kt +++ b/app/src/main/java/be/mygod/reactmap/follower/LocationSetter.kt @@ -26,16 +26,18 @@ class LocationSetter(appContext: Context, workerParams: WorkerParameters) : Coro companion object { const val CHANNEL_ID = "locationSetter" const val CHANNEL_ID_ERROR = "locationSetterError" + const val CHANNEL_ID_SUCCESS = "locationSetterSuccess" const val KEY_LATITUDE = "latitude" const val KEY_LONGITUDE = "longitude" + private const val ID_STATUS = 3 fun notifyError(message: CharSequence) { - app.nm.notify(3, NotificationCompat.Builder(app, CHANNEL_ID_ERROR).apply { - setWhen(0) + app.nm.notify(ID_STATUS, NotificationCompat.Builder(app, CHANNEL_ID_ERROR).apply { color = app.getColor(R.color.main_orange) setCategory(NotificationCompat.CATEGORY_ALARM) setContentTitle("Failed to update location") setContentText(message) + setGroup(CHANNEL_ID) setVisibility(NotificationCompat.VISIBILITY_PUBLIC) setSmallIcon(R.drawable.ic_notification_sync_problem) priority = NotificationCompat.PRIORITY_MAX @@ -72,11 +74,24 @@ class LocationSetter(appContext: Context, workerParams: WorkerParameters) : Coro when (val code = conn.responseCode) { 200 -> { withContext(Dispatchers.Main) { - BackgroundLocationReceiver.onLocationSubmitted(Location("").apply { + BackgroundLocationReceiver.onLocationSubmitted(Location("bg").apply { latitude = lat longitude = lon }) } + app.nm.notify(ID_STATUS, NotificationCompat.Builder(app, CHANNEL_ID_SUCCESS).apply { + color = app.getColor(R.color.main_blue) + setCategory(NotificationCompat.CATEGORY_STATUS) + setContentTitle("Location updated") + setContentText("$lat, $lon") + setGroup(CHANNEL_ID) + setVisibility(NotificationCompat.VISIBILITY_PUBLIC) + setSmallIcon(R.drawable.ic_reactmap) + priority = NotificationCompat.PRIORITY_MIN + setContentIntent(PendingIntent.getActivity(app, 2, + Intent(app, MainActivity::class.java).setAction(MainActivity.ACTION_CONFIGURE), + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)) + }.build()) Result.success() } else -> { @@ -100,12 +115,12 @@ class LocationSetter(appContext: Context, workerParams: WorkerParameters) : Coro } override suspend fun getForegroundInfo() = ForegroundInfo(2, NotificationCompat.Builder(app, CHANNEL_ID).apply { - setWhen(0) color = app.getColor(R.color.main_blue) setCategory(NotificationCompat.CATEGORY_SERVICE) setContentTitle("Updating location") setContentText("${inputData.getDouble(KEY_LATITUDE, Double.NaN)}, " + inputData.getDouble(KEY_LONGITUDE, Double.NaN)) + setGroup(CHANNEL_ID) setVisibility(NotificationCompat.VISIBILITY_PUBLIC) setSmallIcon(R.drawable.ic_notification_sync) setProgress(0, 0, true) From 5e2d7dc4335b264b7816beeadc7c5a0b8a981ba1 Mon Sep 17 00:00:00 2001 From: Mygod Date: Mon, 18 Sep 2023 21:06:45 -0400 Subject: [PATCH 06/26] Improve error display --- .../main/java/be/mygod/reactmap/ConfigDialogFragment.kt | 3 ++- app/src/main/java/be/mygod/reactmap/MainActivity.kt | 3 ++- .../java/be/mygod/reactmap/follower/LocationSetter.kt | 3 ++- app/src/main/java/be/mygod/reactmap/util/Utils.kt | 8 ++++++++ 4 files changed, 14 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/be/mygod/reactmap/ConfigDialogFragment.kt b/app/src/main/java/be/mygod/reactmap/ConfigDialogFragment.kt index eb531d8..be8ea34 100644 --- a/app/src/main/java/be/mygod/reactmap/ConfigDialogFragment.kt +++ b/app/src/main/java/be/mygod/reactmap/ConfigDialogFragment.kt @@ -18,6 +18,7 @@ import be.mygod.reactmap.App.Companion.app import be.mygod.reactmap.follower.BackgroundLocationReceiver import be.mygod.reactmap.util.AlertDialogFragment import be.mygod.reactmap.util.Empty +import be.mygod.reactmap.util.readableMessage import kotlinx.parcelize.Parcelize class ConfigDialogFragment : AlertDialogFragment() { @@ -84,7 +85,7 @@ class ConfigDialogFragment : AlertDialogFragment useParcel(block: (Parcel) -> T) = Parcel.obtain().run { try { block(this) From cd0ade9ff597f0f0d2ff220db4ad4aa69e8fe3ca Mon Sep 17 00:00:00 2001 From: Mygod Date: Mon, 18 Sep 2023 21:27:32 -0400 Subject: [PATCH 07/26] Cache cookie for direct boot retrieval --- .../java/be/mygod/reactmap/MainActivity.kt | 1 + .../be/mygod/reactmap/ReactMapHttpEngine.kt | 25 ++++++++++++++--- .../follower/BackgroundLocationReceiver.kt | 28 +++++++++++-------- .../mygod/reactmap/follower/LocationSetter.kt | 8 ++++-- 4 files changed, 43 insertions(+), 19 deletions(-) diff --git a/app/src/main/java/be/mygod/reactmap/MainActivity.kt b/app/src/main/java/be/mygod/reactmap/MainActivity.kt index e1165c0..f0202db 100644 --- a/app/src/main/java/be/mygod/reactmap/MainActivity.kt +++ b/app/src/main/java/be/mygod/reactmap/MainActivity.kt @@ -154,6 +154,7 @@ class MainActivity : FragmentActivity() { if (url.toUri().host != hostname) return muiMargin.apply() BackgroundLocationReceiver.setup() // redo setup in case cookie is updated + ReactMapHttpEngine.updateCookie() } override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean { diff --git a/app/src/main/java/be/mygod/reactmap/ReactMapHttpEngine.kt b/app/src/main/java/be/mygod/reactmap/ReactMapHttpEngine.kt index 3cf485a..5d97ac0 100644 --- a/app/src/main/java/be/mygod/reactmap/ReactMapHttpEngine.kt +++ b/app/src/main/java/be/mygod/reactmap/ReactMapHttpEngine.kt @@ -3,15 +3,23 @@ package be.mygod.reactmap import android.net.http.ConnectionMigrationOptions import android.net.http.HttpEngine import android.os.Build +import android.os.UserManager import android.os.ext.SdkExtensions import android.webkit.CookieManager import androidx.annotation.RequiresExtension +import androidx.core.content.edit +import androidx.core.content.getSystemService import be.mygod.reactmap.App.Companion.app +import be.mygod.reactmap.follower.LocationSetter import java.io.File import java.net.HttpURLConnection import java.net.URL object ReactMapHttpEngine { + private const val KEY_COOKIE = "cookie.graphql" + + private val userManager = app.getSystemService()!! + @get:RequiresExtension(Build.VERSION_CODES.S, 7) private val engine by lazy @RequiresExtension(Build.VERSION_CODES.S, 7) { val cache = File(app.deviceStorage.cacheDir, "httpEngine") @@ -33,9 +41,18 @@ object ReactMapHttpEngine { SdkExtensions.getExtensionVersion(Build.VERSION_CODES.S) >= 7) { engine.openConnection(URL(url)) } else URL(url).openConnection()) as HttpURLConnection).apply { - val cookie = CookieManager.getInstance() - cookie.getCookie(url)?.let { addRequestProperty("Cookie", it) } - setup() - headerFields["Set-Cookie"]?.forEach { cookie.setCookie(url, it) } + if (userManager.isUserUnlocked) { + val cookie = CookieManager.getInstance() + cookie.getCookie(url)?.let { addRequestProperty("Cookie", it) } + setup() + headerFields["Set-Cookie"]?.forEach { cookie.setCookie(url, it) } + } else { + app.pref.getString(KEY_COOKIE, null)?.let { addRequestProperty("Cookie", it) } + setup() + } + } + + fun updateCookie() = app.pref.edit { + putString(KEY_COOKIE, CookieManager.getInstance().getCookie(LocationSetter.apiUrl)) } } diff --git a/app/src/main/java/be/mygod/reactmap/follower/BackgroundLocationReceiver.kt b/app/src/main/java/be/mygod/reactmap/follower/BackgroundLocationReceiver.kt index a386ce1..46f6b83 100644 --- a/app/src/main/java/be/mygod/reactmap/follower/BackgroundLocationReceiver.kt +++ b/app/src/main/java/be/mygod/reactmap/follower/BackgroundLocationReceiver.kt @@ -93,6 +93,8 @@ class BackgroundLocationReceiver : BroadcastReceiver() { }.build(), locationPendingIntent).addOnCompleteListener { task -> if (task.isSuccessful) active = true else Timber.w(task.exception) } + // for DEV testing: +// enqueueSubmission(persistedLastLocation?.location ?: return) } @MainThread fun stop() { @@ -105,6 +107,19 @@ class BackgroundLocationReceiver : BroadcastReceiver() { fun onLocationSubmitted(location: Location) { persistedLastLocation = LastLocation(persistedLastLocation?.location ?: location, location) } + + private fun enqueueSubmission(location: Location) = app.work.enqueueUniqueWork("LocationSetter", + ExistingWorkPolicy.REPLACE, OneTimeWorkRequestBuilder().apply { + setBackoffCriteria(BackoffPolicy.LINEAR, WorkRequest.MIN_BACKOFF_MILLIS, TimeUnit.MILLISECONDS) + setConstraints(Constraints.Builder().apply { + setRequiredNetworkType(NetworkType.CONNECTED) + // Expedited jobs only support network and storage constraints +// setRequiresBatteryNotLow(true) + }.build()) + setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST) + setInputData(workDataOf(LocationSetter.KEY_LATITUDE to location.latitude, + LocationSetter.KEY_LONGITUDE to location.longitude)) + }.build()) } override fun onReceive(context: Context?, intent: Intent) { @@ -134,18 +149,7 @@ class BackgroundLocationReceiver : BroadcastReceiver() { ?.run { distanceTo(bestLocation) < MIN_UPDATE_THRESHOLD_METER } != true Timber.d("Updating $lastLocation -> $bestLocation (submitting $shouldSet)") persistedLastLocation = LastLocation(bestLocation, lastLocation?.submittedLocation) - if (shouldSet) app.work.enqueueUniqueWork("LocationSetter", ExistingWorkPolicy.REPLACE, - OneTimeWorkRequestBuilder().apply { - setBackoffCriteria(BackoffPolicy.LINEAR, WorkRequest.MIN_BACKOFF_MILLIS, TimeUnit.MILLISECONDS) - setConstraints(Constraints.Builder().apply { - setRequiredNetworkType(NetworkType.CONNECTED) - // Expedited jobs only support network and storage constraints -// setRequiresBatteryNotLow(true) - }.build()) - setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST) - setInputData(workDataOf(LocationSetter.KEY_LATITUDE to bestLocation.latitude, - LocationSetter.KEY_LONGITUDE to bestLocation.longitude)) - }.build()) + if (shouldSet) enqueueSubmission(bestLocation) } } } diff --git a/app/src/main/java/be/mygod/reactmap/follower/LocationSetter.kt b/app/src/main/java/be/mygod/reactmap/follower/LocationSetter.kt index f188f0f..4bfab6a 100644 --- a/app/src/main/java/be/mygod/reactmap/follower/LocationSetter.kt +++ b/app/src/main/java/be/mygod/reactmap/follower/LocationSetter.kt @@ -32,6 +32,10 @@ class LocationSetter(appContext: Context, workerParams: WorkerParameters) : Coro const val KEY_LONGITUDE = "longitude" private const val ID_STATUS = 3 + val apiUrl get() = app.activeUrl.toUri().buildUpon().apply { + path("/graphql") + }.build().toString() + fun notifyError(message: CharSequence) { app.nm.notify(ID_STATUS, NotificationCompat.Builder(app, CHANNEL_ID_ERROR).apply { color = app.getColor(R.color.main_orange) @@ -52,9 +56,7 @@ class LocationSetter(appContext: Context, workerParams: WorkerParameters) : Coro override suspend fun doWork() = try { val lat = inputData.getDouble(KEY_LATITUDE, Double.NaN) val lon = inputData.getDouble(KEY_LONGITUDE, Double.NaN) - val conn = ReactMapHttpEngine.openConnection(app.activeUrl.toUri().buildUpon().apply { - path("/graphql") - }.build().toString()) { + val conn = ReactMapHttpEngine.openConnection(apiUrl) { doOutput = true requestMethod = "POST" addRequestProperty("Content-Type", "application/json") From 08c0750a14e7a51aedfcef3f88fba266638877b6 Mon Sep 17 00:00:00 2001 From: Mygod Date: Mon, 18 Sep 2023 21:59:10 -0400 Subject: [PATCH 08/26] Fix location unavailable before unlock --- app/src/main/java/be/mygod/reactmap/App.kt | 2 ++ .../be/mygod/reactmap/ReactMapHttpEngine.kt | 6 +---- .../follower/BackgroundLocationReceiver.kt | 26 ++++++++++++++++--- 3 files changed, 25 insertions(+), 9 deletions(-) diff --git a/app/src/main/java/be/mygod/reactmap/App.kt b/app/src/main/java/be/mygod/reactmap/App.kt index bd9d632..ff698c0 100644 --- a/app/src/main/java/be/mygod/reactmap/App.kt +++ b/app/src/main/java/be/mygod/reactmap/App.kt @@ -8,6 +8,7 @@ import android.app.NotificationManager import android.content.Context import android.net.Uri import android.os.Build +import android.os.UserManager import android.os.ext.SdkExtensions import android.util.Log import android.widget.Toast @@ -40,6 +41,7 @@ class App : Application() { val pref by lazy { deviceStorage.getSharedPreferences(PREF_NAME, MODE_PRIVATE) } val fusedLocation by lazy { LocationServices.getFusedLocationProviderClient(deviceStorage) } val nm by lazy { getSystemService()!! } + val userManager by lazy { getSystemService()!! } val activeUrl get() = pref.getString(KEY_ACTIVE_URL, URL_DEFAULT) ?: URL_DEFAULT diff --git a/app/src/main/java/be/mygod/reactmap/ReactMapHttpEngine.kt b/app/src/main/java/be/mygod/reactmap/ReactMapHttpEngine.kt index 5d97ac0..b6d23fa 100644 --- a/app/src/main/java/be/mygod/reactmap/ReactMapHttpEngine.kt +++ b/app/src/main/java/be/mygod/reactmap/ReactMapHttpEngine.kt @@ -3,12 +3,10 @@ package be.mygod.reactmap import android.net.http.ConnectionMigrationOptions import android.net.http.HttpEngine import android.os.Build -import android.os.UserManager import android.os.ext.SdkExtensions import android.webkit.CookieManager import androidx.annotation.RequiresExtension import androidx.core.content.edit -import androidx.core.content.getSystemService import be.mygod.reactmap.App.Companion.app import be.mygod.reactmap.follower.LocationSetter import java.io.File @@ -18,8 +16,6 @@ import java.net.URL object ReactMapHttpEngine { private const val KEY_COOKIE = "cookie.graphql" - private val userManager = app.getSystemService()!! - @get:RequiresExtension(Build.VERSION_CODES.S, 7) private val engine by lazy @RequiresExtension(Build.VERSION_CODES.S, 7) { val cache = File(app.deviceStorage.cacheDir, "httpEngine") @@ -41,7 +37,7 @@ object ReactMapHttpEngine { SdkExtensions.getExtensionVersion(Build.VERSION_CODES.S) >= 7) { engine.openConnection(URL(url)) } else URL(url).openConnection()) as HttpURLConnection).apply { - if (userManager.isUserUnlocked) { + if (app.userManager.isUserUnlocked) { val cookie = CookieManager.getInstance() cookie.getCookie(url)?.let { addRequestProperty("Cookie", it) } setup() diff --git a/app/src/main/java/be/mygod/reactmap/follower/BackgroundLocationReceiver.kt b/app/src/main/java/be/mygod/reactmap/follower/BackgroundLocationReceiver.kt index 46f6b83..415cbe1 100644 --- a/app/src/main/java/be/mygod/reactmap/follower/BackgroundLocationReceiver.kt +++ b/app/src/main/java/be/mygod/reactmap/follower/BackgroundLocationReceiver.kt @@ -6,6 +6,7 @@ import android.content.BroadcastReceiver import android.content.ComponentName import android.content.Context import android.content.Intent +import android.content.IntentFilter import android.content.pm.PackageManager import android.location.Location import androidx.annotation.MainThread @@ -18,8 +19,10 @@ import androidx.work.OutOfQuotaPolicy import androidx.work.WorkRequest import androidx.work.workDataOf import be.mygod.reactmap.App.Companion.app +import be.mygod.reactmap.util.readableMessage import be.mygod.reactmap.util.toByteArray import be.mygod.reactmap.util.toParcelable +import com.google.android.gms.common.api.ApiException import com.google.android.gms.location.LocationRequest import com.google.android.gms.location.LocationResult import com.google.android.gms.location.Priority @@ -91,7 +94,13 @@ class BackgroundLocationReceiver : BroadcastReceiver() { setMinUpdateDistanceMeters(MIN_UPDATE_THRESHOLD_METER) setMinUpdateIntervalMillis(60 * 1000) }.build(), locationPendingIntent).addOnCompleteListener { task -> - if (task.isSuccessful) active = true else Timber.w(task.exception) + if (task.isSuccessful) { + Timber.d("BackgroundLocationReceiver started") + active = true + } else if (app.userManager.isUserUnlocked || task.exception !is ApiException) { + LocationSetter.notifyError(task.exception?.readableMessage.toString()) + Timber.w(task.exception) + } else Timber.d(task.exception) // would retry after unlock } // for DEV testing: // enqueueSubmission(persistedLastLocation?.location ?: return) @@ -99,7 +108,10 @@ class BackgroundLocationReceiver : BroadcastReceiver() { @MainThread fun stop() { if (active) app.fusedLocation.removeLocationUpdates(locationPendingIntent).addOnCompleteListener { task -> - if (task.isSuccessful) active = false else Timber.w(task.exception) + if (task.isSuccessful) { + Timber.d("BackgroundLocationReceiver stopped") + active = false + } else Timber.w(task.exception) } } @@ -122,11 +134,17 @@ class BackgroundLocationReceiver : BroadcastReceiver() { }.build()) } - override fun onReceive(context: Context?, intent: Intent) { + override fun onReceive(context: Context, intent: Intent) { when (intent.action) { - Intent.ACTION_BOOT_COMPLETED, Intent.ACTION_LOCKED_BOOT_COMPLETED, Intent.ACTION_MY_PACKAGE_REPLACED -> { + Intent.ACTION_BOOT_COMPLETED, Intent.ACTION_MY_PACKAGE_REPLACED -> { // already handled during App init } + Intent.ACTION_LOCKED_BOOT_COMPLETED -> app.registerReceiver(object : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent?) { + setup() + context.unregisterReceiver(this) + } + }, IntentFilter(Intent.ACTION_USER_UNLOCKED)) ACTION_LOCATION -> { val locations = LocationResult.extractResult(intent)?.locations if (locations.isNullOrEmpty()) return From b43255928b59255e9948a322dc00ed615d831c4f Mon Sep 17 00:00:00 2001 From: Mygod Date: Mon, 18 Sep 2023 22:05:41 -0400 Subject: [PATCH 09/26] Bump versionCode --- app/build.gradle.kts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index f7b0d52..a93c631 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -14,7 +14,7 @@ android { applicationId = "be.mygod.reactmap" minSdk = 24 targetSdk = 34 - versionCode = 50 + versionCode = 51 versionName = "0.5.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" From cec853f0f8c96f646e57fff02659c518e395774a Mon Sep 17 00:00:00 2001 From: Mygod Date: Mon, 18 Sep 2023 22:38:34 -0400 Subject: [PATCH 10/26] Add a disclaimer (do not sue me) --- app/src/main/java/be/mygod/reactmap/ConfigDialogFragment.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/be/mygod/reactmap/ConfigDialogFragment.kt b/app/src/main/java/be/mygod/reactmap/ConfigDialogFragment.kt index be8ea34..9dcb6f3 100644 --- a/app/src/main/java/be/mygod/reactmap/ConfigDialogFragment.kt +++ b/app/src/main/java/be/mygod/reactmap/ConfigDialogFragment.kt @@ -45,7 +45,7 @@ class ConfigDialogFragment : AlertDialogFragment From a33bc8cf681b6fe05b30a7c727aaadb9dfca3a14 Mon Sep 17 00:00:00 2001 From: Mygod Date: Mon, 18 Sep 2023 22:42:34 -0400 Subject: [PATCH 11/26] Update README --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 2495795..f8c0c13 100644 --- a/README.md +++ b/README.md @@ -10,3 +10,4 @@ Other features: * See map even behind your status bar and navigation bar! * More conveniently refresh the webpage when fullscreen. (During network congestion, Chrome HTTP/3 scheduler retries requests at its own pace. Doing a manual refresh overrides this.) * Login button is clicked automatically. +* You could make alerts follow location in background. From 43a8923eb6b17df21f6bb972c0af535a691cee26 Mon Sep 17 00:00:00 2001 From: Mygod Date: Tue, 19 Sep 2023 13:45:12 -0400 Subject: [PATCH 12/26] Suppress useless logs --- .../main/java/be/mygod/reactmap/follower/LocationSetter.kt | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/be/mygod/reactmap/follower/LocationSetter.kt b/app/src/main/java/be/mygod/reactmap/follower/LocationSetter.kt index 4bfab6a..3e2ed6f 100644 --- a/app/src/main/java/be/mygod/reactmap/follower/LocationSetter.kt +++ b/app/src/main/java/be/mygod/reactmap/follower/LocationSetter.kt @@ -99,13 +99,15 @@ class LocationSetter(appContext: Context, workerParams: WorkerParameters) : Coro } else -> { val error = conn.findErrorStream.bufferedReader().readText() - Timber.w(Exception(error)) val json = JSONObject(error).getJSONArray("errors") notifyError((0 until json.length()).joinToString { json.getJSONObject(it).getString("message") }) if (code == 401 || code == 511) { withContext(Dispatchers.Main) { BackgroundLocationReceiver.stop() } Result.failure() - } else Result.retry() + } else { + Timber.w(Exception(error + code)) + Result.retry() + } } } } catch (e: IOException) { From 8e72676f5bd7bec6d1149b16289059197583324c Mon Sep 17 00:00:00 2001 From: Mygod Date: Tue, 19 Sep 2023 14:02:26 -0400 Subject: [PATCH 13/26] Show which user was updated in the notification --- app/src/main/java/be/mygod/reactmap/App.kt | 4 +--- .../be/mygod/reactmap/follower/LocationSetter.kt | 14 +++++++++++--- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/be/mygod/reactmap/App.kt b/app/src/main/java/be/mygod/reactmap/App.kt index ff698c0..8d54382 100644 --- a/app/src/main/java/be/mygod/reactmap/App.kt +++ b/app/src/main/java/be/mygod/reactmap/App.kt @@ -84,9 +84,7 @@ class App : Application() { lockscreenVisibility = Notification.VISIBILITY_PUBLIC }, NotificationChannel(LocationSetter.CHANNEL_ID_SUCCESS, "Background location updated", - NotificationManager.IMPORTANCE_MIN).apply { - lockscreenVisibility = Notification.VISIBILITY_PUBLIC - }, + NotificationManager.IMPORTANCE_MIN), NotificationChannel(LocationSetter.CHANNEL_ID_ERROR, "Background location update failed", NotificationManager.IMPORTANCE_HIGH).apply { lockscreenVisibility = Notification.VISIBILITY_PUBLIC diff --git a/app/src/main/java/be/mygod/reactmap/follower/LocationSetter.kt b/app/src/main/java/be/mygod/reactmap/follower/LocationSetter.kt index 3e2ed6f..3d7f83f 100644 --- a/app/src/main/java/be/mygod/reactmap/follower/LocationSetter.kt +++ b/app/src/main/java/be/mygod/reactmap/follower/LocationSetter.kt @@ -19,6 +19,7 @@ import be.mygod.reactmap.util.readableMessage import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import org.json.JSONArray +import org.json.JSONException import org.json.JSONObject import timber.log.Timber import java.io.IOException @@ -70,12 +71,19 @@ class LocationSetter(appContext: Context, workerParams: WorkerParameters) : Coro }) // epic graphql query yay >:( put("query", "mutation Webhook(\$data: JSON, \$category: String!, \$status: String!) {" + - "webhook(data: \$data, category: \$category, status: \$status) { __typename } }") + "webhook(data: \$data, category: \$category, status: \$status) { human { name } } }") }.toString()) } } when (val code = conn.responseCode) { 200 -> { + val response = conn.inputStream.bufferedReader().readText() + val human = try { + JSONObject(response).getJSONObject("data").getJSONObject("webhook").getJSONObject("human") + .getString("name") + } catch (e: JSONException) { + throw Exception(response, e) + } withContext(Dispatchers.Main) { BackgroundLocationReceiver.onLocationSubmitted(Location("bg").apply { latitude = lat @@ -86,14 +94,14 @@ class LocationSetter(appContext: Context, workerParams: WorkerParameters) : Coro color = app.getColor(R.color.main_blue) setCategory(NotificationCompat.CATEGORY_STATUS) setContentTitle("Location updated") - setContentText("$lat, $lon") setGroup(CHANNEL_ID) - setVisibility(NotificationCompat.VISIBILITY_PUBLIC) setSmallIcon(R.drawable.ic_reactmap) priority = NotificationCompat.PRIORITY_MIN setContentIntent(PendingIntent.getActivity(app, 2, Intent(app, MainActivity::class.java).setAction(MainActivity.ACTION_CONFIGURE), PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)) + setPublicVersion(build()) + setContentText("$lat, $lon for $human") }.build()) Result.success() } From 9c282ed3d29203da82c6e514c674d5316ee50647 Mon Sep 17 00:00:00 2001 From: Mygod Date: Tue, 19 Sep 2023 15:51:26 -0400 Subject: [PATCH 14/26] Add more info to the notif --- .../follower/BackgroundLocationReceiver.kt | 2 +- .../mygod/reactmap/follower/LocationSetter.kt | 35 ++++++++++++++++--- 2 files changed, 31 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/be/mygod/reactmap/follower/BackgroundLocationReceiver.kt b/app/src/main/java/be/mygod/reactmap/follower/BackgroundLocationReceiver.kt index 415cbe1..e744016 100644 --- a/app/src/main/java/be/mygod/reactmap/follower/BackgroundLocationReceiver.kt +++ b/app/src/main/java/be/mygod/reactmap/follower/BackgroundLocationReceiver.kt @@ -130,7 +130,7 @@ class BackgroundLocationReceiver : BroadcastReceiver() { }.build()) setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST) setInputData(workDataOf(LocationSetter.KEY_LATITUDE to location.latitude, - LocationSetter.KEY_LONGITUDE to location.longitude)) + LocationSetter.KEY_LONGITUDE to location.longitude, LocationSetter.KEY_TIME to location.time)) }.build()) } diff --git a/app/src/main/java/be/mygod/reactmap/follower/LocationSetter.kt b/app/src/main/java/be/mygod/reactmap/follower/LocationSetter.kt index 3d7f83f..7783e04 100644 --- a/app/src/main/java/be/mygod/reactmap/follower/LocationSetter.kt +++ b/app/src/main/java/be/mygod/reactmap/follower/LocationSetter.kt @@ -5,6 +5,7 @@ import android.content.Context import android.content.Intent import android.content.pm.ServiceInfo import android.location.Location +import android.text.format.DateUtils import androidx.core.app.NotificationCompat import androidx.core.net.toUri import androidx.work.CoroutineWorker @@ -31,6 +32,7 @@ class LocationSetter(appContext: Context, workerParams: WorkerParameters) : Coro const val CHANNEL_ID_SUCCESS = "locationSetterSuccess" const val KEY_LATITUDE = "latitude" const val KEY_LONGITUDE = "longitude" + const val KEY_TIME = "time" private const val ID_STATUS = 3 val apiUrl get() = app.activeUrl.toUri().buildUpon().apply { @@ -52,11 +54,32 @@ class LocationSetter(appContext: Context, workerParams: WorkerParameters) : Coro PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)) }.build()) } + + private fun timeSpan(from: Long): String { + var t = (System.currentTimeMillis() - from) * .001 + if (t < 60) return "${t}s" + if (t < 60 * 60) { + val s = (t % 60).toInt() + if (s == 0) return "${t.toInt()}m" + return "${t.toInt()}m ${s}s" + } + t /= 60 + if (t < 24 * 60) { + val m = (t % 60).toInt() + if (m == 0) return "${t.toInt()}h" + return "${t.toInt()}h ${m}m" + } + t /= 60 + val h = (t % 24).toInt() + if (h == 0) return "${t.toInt()}d" + return "${t.toInt()}d ${h}h" + } } override suspend fun doWork() = try { val lat = inputData.getDouble(KEY_LATITUDE, Double.NaN) val lon = inputData.getDouble(KEY_LONGITUDE, Double.NaN) + val time = inputData.getLong(KEY_TIME, 0) val conn = ReactMapHttpEngine.openConnection(apiUrl) { doOutput = true requestMethod = "POST" @@ -71,7 +94,7 @@ class LocationSetter(appContext: Context, workerParams: WorkerParameters) : Coro }) // epic graphql query yay >:( put("query", "mutation Webhook(\$data: JSON, \$category: String!, \$status: String!) {" + - "webhook(data: \$data, category: \$category, status: \$status) { human { name } } }") + "webhook(data: \$data, category: \$category, status: \$status) { human { name type } } }") }.toString()) } } @@ -79,8 +102,8 @@ class LocationSetter(appContext: Context, workerParams: WorkerParameters) : Coro 200 -> { val response = conn.inputStream.bufferedReader().readText() val human = try { - JSONObject(response).getJSONObject("data").getJSONObject("webhook").getJSONObject("human") - .getString("name") + val o = JSONObject(response).getJSONObject("data").getJSONObject("webhook").getJSONObject("human") + o.getString("type") + ' ' + o.getString("name") } catch (e: JSONException) { throw Exception(response, e) } @@ -101,7 +124,7 @@ class LocationSetter(appContext: Context, workerParams: WorkerParameters) : Coro Intent(app, MainActivity::class.java).setAction(MainActivity.ACTION_CONFIGURE), PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)) setPublicVersion(build()) - setContentText("$lat, $lon for $human") + setContentText("$lat, $lon (stale ${timeSpan(time)}) for $human") }.build()) Result.success() } @@ -132,7 +155,9 @@ class LocationSetter(appContext: Context, workerParams: WorkerParameters) : Coro setCategory(NotificationCompat.CATEGORY_SERVICE) setContentTitle("Updating location") setContentText("${inputData.getDouble(KEY_LATITUDE, Double.NaN)}, " + - inputData.getDouble(KEY_LONGITUDE, Double.NaN)) + inputData.getDouble(KEY_LONGITUDE, Double.NaN) + " from " + + DateUtils.getRelativeTimeSpanString(inputData.getLong(KEY_TIME, 0), + System.currentTimeMillis(), 0, DateUtils.FORMAT_ABBREV_RELATIVE)) setGroup(CHANNEL_ID) setVisibility(NotificationCompat.VISIBILITY_PUBLIC) setSmallIcon(R.drawable.ic_notification_sync) From 48947e68f88eb6e59151d7834b768607370937a4 Mon Sep 17 00:00:00 2001 From: Mygod Date: Tue, 19 Sep 2023 15:59:11 -0400 Subject: [PATCH 15/26] Handle CancellationException --- app/src/main/java/be/mygod/reactmap/follower/LocationSetter.kt | 3 +++ 1 file changed, 3 insertions(+) diff --git a/app/src/main/java/be/mygod/reactmap/follower/LocationSetter.kt b/app/src/main/java/be/mygod/reactmap/follower/LocationSetter.kt index 7783e04..da25e4c 100644 --- a/app/src/main/java/be/mygod/reactmap/follower/LocationSetter.kt +++ b/app/src/main/java/be/mygod/reactmap/follower/LocationSetter.kt @@ -17,6 +17,7 @@ import be.mygod.reactmap.R import be.mygod.reactmap.ReactMapHttpEngine import be.mygod.reactmap.util.findErrorStream import be.mygod.reactmap.util.readableMessage +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import org.json.JSONArray @@ -144,6 +145,8 @@ class LocationSetter(appContext: Context, workerParams: WorkerParameters) : Coro } catch (e: IOException) { Timber.d(e) Result.retry() + } catch (_: CancellationException) { + Result.failure() } catch (e: Exception) { Timber.w(e) notifyError(e.readableMessage) From 9de92ffd5934785a6493b07b5ad8ad86230fc4bc Mon Sep 17 00:00:00 2001 From: Mygod Date: Tue, 19 Sep 2023 16:20:26 -0400 Subject: [PATCH 16/26] Make workmanager directbootaware --- app/src/main/AndroidManifest.xml | 42 ++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 263b7ae..d27f5ad 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -44,10 +44,52 @@ + + + + + + + + + + From 4467e97aa40f8c18dfe90e4ec160343d2834491f Mon Sep 17 00:00:00 2001 From: Mygod Date: Tue, 19 Sep 2023 18:53:49 -0400 Subject: [PATCH 17/26] Clean up --- .../main/java/be/mygod/reactmap/ConfigDialogFragment.kt | 2 +- app/src/main/java/be/mygod/reactmap/MainActivity.kt | 2 +- .../mygod/reactmap/follower/BackgroundLocationReceiver.kt | 7 ++----- 3 files changed, 4 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/be/mygod/reactmap/ConfigDialogFragment.kt b/app/src/main/java/be/mygod/reactmap/ConfigDialogFragment.kt index 9dcb6f3..2841e72 100644 --- a/app/src/main/java/be/mygod/reactmap/ConfigDialogFragment.kt +++ b/app/src/main/java/be/mygod/reactmap/ConfigDialogFragment.kt @@ -87,5 +87,5 @@ class ConfigDialogFragment : AlertDialogFragment Date: Tue, 19 Sep 2023 18:56:32 -0400 Subject: [PATCH 18/26] Add current_profile_no to notif --- .../main/java/be/mygod/reactmap/follower/LocationSetter.kt | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/be/mygod/reactmap/follower/LocationSetter.kt b/app/src/main/java/be/mygod/reactmap/follower/LocationSetter.kt index da25e4c..956a4fd 100644 --- a/app/src/main/java/be/mygod/reactmap/follower/LocationSetter.kt +++ b/app/src/main/java/be/mygod/reactmap/follower/LocationSetter.kt @@ -95,7 +95,8 @@ class LocationSetter(appContext: Context, workerParams: WorkerParameters) : Coro }) // epic graphql query yay >:( put("query", "mutation Webhook(\$data: JSON, \$category: String!, \$status: String!) {" + - "webhook(data: \$data, category: \$category, status: \$status) { human { name type } } }") + "webhook(data: \$data, category: \$category, status: \$status) {" + + "human { current_profile_no name type } } }") }.toString()) } } @@ -104,7 +105,7 @@ class LocationSetter(appContext: Context, workerParams: WorkerParameters) : Coro val response = conn.inputStream.bufferedReader().readText() val human = try { val o = JSONObject(response).getJSONObject("data").getJSONObject("webhook").getJSONObject("human") - o.getString("type") + ' ' + o.getString("name") + o.getString("type") + ' ' + o.getString("name") + " profile#" + o.getLong("current_profile_no") } catch (e: JSONException) { throw Exception(response, e) } From 512b599b8b97b1d6d0ef53788bbbf39ea657f40a Mon Sep 17 00:00:00 2001 From: Mygod Date: Tue, 19 Sep 2023 19:29:55 -0400 Subject: [PATCH 19/26] I was drunk --- .../main/java/be/mygod/reactmap/follower/LocationSetter.kt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/be/mygod/reactmap/follower/LocationSetter.kt b/app/src/main/java/be/mygod/reactmap/follower/LocationSetter.kt index 956a4fd..d858538 100644 --- a/app/src/main/java/be/mygod/reactmap/follower/LocationSetter.kt +++ b/app/src/main/java/be/mygod/reactmap/follower/LocationSetter.kt @@ -62,18 +62,18 @@ class LocationSetter(appContext: Context, workerParams: WorkerParameters) : Coro if (t < 60 * 60) { val s = (t % 60).toInt() if (s == 0) return "${t.toInt()}m" - return "${t.toInt()}m ${s}s" + return "${(t / 60).toInt()}m ${s}s" } t /= 60 if (t < 24 * 60) { val m = (t % 60).toInt() if (m == 0) return "${t.toInt()}h" - return "${t.toInt()}h ${m}m" + return "${(t / 60).toInt()}h ${m}m" } t /= 60 val h = (t % 24).toInt() if (h == 0) return "${t.toInt()}d" - return "${t.toInt()}d ${h}h" + return "${(t / 24).toInt()}d ${h}h" } } From 35b1d7b0e2e878ee44a0aac79269d35f14e31099 Mon Sep 17 00:00:00 2001 From: Mygod Date: Tue, 19 Sep 2023 22:12:58 -0400 Subject: [PATCH 20/26] Refine notif format --- .../main/java/be/mygod/reactmap/follower/LocationSetter.kt | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/be/mygod/reactmap/follower/LocationSetter.kt b/app/src/main/java/be/mygod/reactmap/follower/LocationSetter.kt index d858538..a4382da 100644 --- a/app/src/main/java/be/mygod/reactmap/follower/LocationSetter.kt +++ b/app/src/main/java/be/mygod/reactmap/follower/LocationSetter.kt @@ -4,6 +4,7 @@ import android.app.PendingIntent import android.content.Context import android.content.Intent import android.content.pm.ServiceInfo +import android.icu.text.DecimalFormat import android.location.Location import android.text.format.DateUtils import androidx.core.app.NotificationCompat @@ -56,9 +57,10 @@ class LocationSetter(appContext: Context, workerParams: WorkerParameters) : Coro }.build()) } + private val secondFormat = DecimalFormat(".###") private fun timeSpan(from: Long): String { var t = (System.currentTimeMillis() - from) * .001 - if (t < 60) return "${t}s" + if (t < 60) return "${secondFormat.format(t)}s" if (t < 60 * 60) { val s = (t % 60).toInt() if (s == 0) return "${t.toInt()}m" @@ -126,7 +128,7 @@ class LocationSetter(appContext: Context, workerParams: WorkerParameters) : Coro Intent(app, MainActivity::class.java).setAction(MainActivity.ACTION_CONFIGURE), PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)) setPublicVersion(build()) - setContentText("$lat, $lon (stale ${timeSpan(time)}) for $human") + setContentText("$lat,$lon (stale ${timeSpan(time)}) > $human") }.build()) Result.success() } From 5c2aab6bc50d28ef24385d4b62f31437ab31d194 Mon Sep 17 00:00:00 2001 From: Mygod Date: Wed, 20 Sep 2023 01:53:57 -0400 Subject: [PATCH 21/26] Counteract drifting by setting threshold to 3 sigma --- .../mygod/reactmap/follower/BackgroundLocationReceiver.kt | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/be/mygod/reactmap/follower/BackgroundLocationReceiver.kt b/app/src/main/java/be/mygod/reactmap/follower/BackgroundLocationReceiver.kt index 40d98e8..a594504 100644 --- a/app/src/main/java/be/mygod/reactmap/follower/BackgroundLocationReceiver.kt +++ b/app/src/main/java/be/mygod/reactmap/follower/BackgroundLocationReceiver.kt @@ -152,9 +152,10 @@ class BackgroundLocationReceiver : BroadcastReceiver() { if (bestLocation.time > location.time) continue if (bestLocation.hasAccuracy()) { if (!location.hasAccuracy()) continue - // keep the old more accurate location if new estimate does not contradict old estimate - if (bestLocation.accuracy < location.accuracy && - location.distanceTo(bestLocation) <= bestLocation.accuracy + location.accuracy) continue + // keep the old more accurate location if the new estimate does not contradict + // the old estimate by up to 3 sigma + if (bestLocation.accuracy < location.accuracy && location.distanceTo(bestLocation) <= + 3 * (bestLocation.accuracy + location.accuracy)) continue } } bestLocation = location From 974b7f7d41029445cf362d78ac764c92a5fc28f9 Mon Sep 17 00:00:00 2001 From: Mygod Date: Wed, 20 Sep 2023 01:54:07 -0400 Subject: [PATCH 22/26] Shorten notif content --- app/src/main/java/be/mygod/reactmap/follower/LocationSetter.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/be/mygod/reactmap/follower/LocationSetter.kt b/app/src/main/java/be/mygod/reactmap/follower/LocationSetter.kt index a4382da..252b644 100644 --- a/app/src/main/java/be/mygod/reactmap/follower/LocationSetter.kt +++ b/app/src/main/java/be/mygod/reactmap/follower/LocationSetter.kt @@ -107,7 +107,7 @@ class LocationSetter(appContext: Context, workerParams: WorkerParameters) : Coro val response = conn.inputStream.bufferedReader().readText() val human = try { val o = JSONObject(response).getJSONObject("data").getJSONObject("webhook").getJSONObject("human") - o.getString("type") + ' ' + o.getString("name") + " profile#" + o.getLong("current_profile_no") + o.getString("type") + '/' + o.getString("name") + '/' + o.getLong("current_profile_no") } catch (e: JSONException) { throw Exception(response, e) } From 9bfc497f114633b176acfc04f122942ff6f3c914 Mon Sep 17 00:00:00 2001 From: Mygod Date: Wed, 20 Sep 2023 13:22:42 -0400 Subject: [PATCH 23/26] Handle url change for follower --- .../be/mygod/reactmap/ConfigDialogFragment.kt | 2 ++ .../java/be/mygod/reactmap/ReactMapHttpEngine.kt | 8 ++++++-- .../follower/BackgroundLocationReceiver.kt | 16 +++++++++++++--- .../be/mygod/reactmap/follower/LocationSetter.kt | 13 +++++-------- 4 files changed, 26 insertions(+), 13 deletions(-) diff --git a/app/src/main/java/be/mygod/reactmap/ConfigDialogFragment.kt b/app/src/main/java/be/mygod/reactmap/ConfigDialogFragment.kt index 2841e72..860e2b5 100644 --- a/app/src/main/java/be/mygod/reactmap/ConfigDialogFragment.kt +++ b/app/src/main/java/be/mygod/reactmap/ConfigDialogFragment.kt @@ -79,10 +79,12 @@ class ConfigDialogFragment : AlertDialogFragment Unit) = ((if (Build.VERSION.SDK_INT >= 34 || Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && SdkExtensions.getExtensionVersion(Build.VERSION_CODES.S) >= 7) { @@ -49,6 +53,6 @@ object ReactMapHttpEngine { } fun updateCookie() = app.pref.edit { - putString(KEY_COOKIE, CookieManager.getInstance().getCookie(LocationSetter.apiUrl)) + putString(KEY_COOKIE, CookieManager.getInstance().getCookie(apiUrl)) } } diff --git a/app/src/main/java/be/mygod/reactmap/follower/BackgroundLocationReceiver.kt b/app/src/main/java/be/mygod/reactmap/follower/BackgroundLocationReceiver.kt index a594504..b08059f 100644 --- a/app/src/main/java/be/mygod/reactmap/follower/BackgroundLocationReceiver.kt +++ b/app/src/main/java/be/mygod/reactmap/follower/BackgroundLocationReceiver.kt @@ -19,6 +19,7 @@ import androidx.work.OutOfQuotaPolicy import androidx.work.WorkRequest import androidx.work.workDataOf import be.mygod.reactmap.App.Companion.app +import be.mygod.reactmap.ReactMapHttpEngine import be.mygod.reactmap.util.readableMessage import be.mygod.reactmap.util.toByteArray import be.mygod.reactmap.util.toParcelable @@ -113,10 +114,16 @@ class BackgroundLocationReceiver : BroadcastReceiver() { } @MainThread - fun onLocationSubmitted(location: Location) { + fun onLocationSubmitted(apiUrl: String, location: Location) { + if (apiUrl != ReactMapHttpEngine.apiUrl) return persistedLastLocation = LastLocation(persistedLastLocation?.location ?: location, location) } + fun onApiChanged() { + stop() + persistedLastLocation = null + } + private fun enqueueSubmission(location: Location) = app.work.enqueueUniqueWork("LocationSetter", ExistingWorkPolicy.REPLACE, OneTimeWorkRequestBuilder().apply { setBackoffCriteria(BackoffPolicy.LINEAR, WorkRequest.MIN_BACKOFF_MILLIS, TimeUnit.MILLISECONDS) @@ -126,8 +133,11 @@ class BackgroundLocationReceiver : BroadcastReceiver() { // setRequiresBatteryNotLow(true) }.build()) setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST) - setInputData(workDataOf(LocationSetter.KEY_LATITUDE to location.latitude, - LocationSetter.KEY_LONGITUDE to location.longitude, LocationSetter.KEY_TIME to location.time)) + setInputData(workDataOf( + LocationSetter.KEY_LATITUDE to location.latitude, + LocationSetter.KEY_LONGITUDE to location.longitude, + LocationSetter.KEY_TIME to location.time, + LocationSetter.KEY_API_URL to ReactMapHttpEngine.apiUrl)) }.build()) } diff --git a/app/src/main/java/be/mygod/reactmap/follower/LocationSetter.kt b/app/src/main/java/be/mygod/reactmap/follower/LocationSetter.kt index 252b644..2458aed 100644 --- a/app/src/main/java/be/mygod/reactmap/follower/LocationSetter.kt +++ b/app/src/main/java/be/mygod/reactmap/follower/LocationSetter.kt @@ -8,7 +8,6 @@ import android.icu.text.DecimalFormat import android.location.Location import android.text.format.DateUtils import androidx.core.app.NotificationCompat -import androidx.core.net.toUri import androidx.work.CoroutineWorker import androidx.work.ForegroundInfo import androidx.work.WorkerParameters @@ -35,12 +34,9 @@ class LocationSetter(appContext: Context, workerParams: WorkerParameters) : Coro const val KEY_LATITUDE = "latitude" const val KEY_LONGITUDE = "longitude" const val KEY_TIME = "time" + const val KEY_API_URL = "apiUrl" private const val ID_STATUS = 3 - val apiUrl get() = app.activeUrl.toUri().buildUpon().apply { - path("/graphql") - }.build().toString() - fun notifyError(message: CharSequence) { app.nm.notify(ID_STATUS, NotificationCompat.Builder(app, CHANNEL_ID_ERROR).apply { color = app.getColor(R.color.main_orange) @@ -58,7 +54,7 @@ class LocationSetter(appContext: Context, workerParams: WorkerParameters) : Coro } private val secondFormat = DecimalFormat(".###") - private fun timeSpan(from: Long): String { + private fun formatTimeSpanFrom(from: Long): String { var t = (System.currentTimeMillis() - from) * .001 if (t < 60) return "${secondFormat.format(t)}s" if (t < 60 * 60) { @@ -83,6 +79,7 @@ class LocationSetter(appContext: Context, workerParams: WorkerParameters) : Coro val lat = inputData.getDouble(KEY_LATITUDE, Double.NaN) val lon = inputData.getDouble(KEY_LONGITUDE, Double.NaN) val time = inputData.getLong(KEY_TIME, 0) + val apiUrl = inputData.getString(KEY_API_URL)!! val conn = ReactMapHttpEngine.openConnection(apiUrl) { doOutput = true requestMethod = "POST" @@ -112,7 +109,7 @@ class LocationSetter(appContext: Context, workerParams: WorkerParameters) : Coro throw Exception(response, e) } withContext(Dispatchers.Main) { - BackgroundLocationReceiver.onLocationSubmitted(Location("bg").apply { + BackgroundLocationReceiver.onLocationSubmitted(apiUrl, Location("bg").apply { latitude = lat longitude = lon }) @@ -128,7 +125,7 @@ class LocationSetter(appContext: Context, workerParams: WorkerParameters) : Coro Intent(app, MainActivity::class.java).setAction(MainActivity.ACTION_CONFIGURE), PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)) setPublicVersion(build()) - setContentText("$lat,$lon (stale ${timeSpan(time)}) > $human") + setContentText("$lat,$lon (stale ${formatTimeSpanFrom(time)}) > $human") }.build()) Result.success() } From 07a418084bdf4ba672597e5b9da60f92f1d4fae4 Mon Sep 17 00:00:00 2001 From: Mygod Date: Wed, 20 Sep 2023 13:23:46 -0400 Subject: [PATCH 24/26] Test: decrease update threshold to 20m --- .../be/mygod/reactmap/follower/BackgroundLocationReceiver.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/be/mygod/reactmap/follower/BackgroundLocationReceiver.kt b/app/src/main/java/be/mygod/reactmap/follower/BackgroundLocationReceiver.kt index b08059f..98aa3bf 100644 --- a/app/src/main/java/be/mygod/reactmap/follower/BackgroundLocationReceiver.kt +++ b/app/src/main/java/be/mygod/reactmap/follower/BackgroundLocationReceiver.kt @@ -35,7 +35,7 @@ import java.util.concurrent.TimeUnit class BackgroundLocationReceiver : BroadcastReceiver() { companion object { private const val ACTION_LOCATION = "location" - const val MIN_UPDATE_THRESHOLD_METER = 40f + const val MIN_UPDATE_THRESHOLD_METER = 20f private val componentName by lazy { ComponentName(app, BackgroundLocationReceiver::class.java) } @get:MainThread @set:MainThread From 535f9b9455cb10decd1a91161f636713f389c74d Mon Sep 17 00:00:00 2001 From: Mygod Date: Wed, 20 Sep 2023 14:27:51 -0400 Subject: [PATCH 25/26] v0.5.1 --- app/build.gradle.kts | 4 ++-- gradle/libs.versions.toml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index a93c631..7ecc3d1 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -14,8 +14,8 @@ android { applicationId = "be.mygod.reactmap" minSdk = 24 targetSdk = 34 - versionCode = 51 - versionName = "0.5.0" + versionCode = 52 + versionName = "0.5.1" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index f822898..3b02c38 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,5 +1,5 @@ [versions] -activity = "1.8.0-beta01" +activity = "1.8.0-rc01" agp = "8.3.0-alpha04" androidx-test-ext-junit = "1.1.5" browser = "1.6.0" From 3525a456485d9c63bc2f4250da00ced69d3933b4 Mon Sep 17 00:00:00 2001 From: Mygod Date: Wed, 20 Sep 2023 17:11:27 -0400 Subject: [PATCH 26/26] Add telegram login support No support for auto-click login button though. I tried. --- README.md | 2 +- app/src/main/java/be/mygod/reactmap/MainActivity.kt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index f8c0c13..28c82b7 100644 --- a/README.md +++ b/README.md @@ -9,5 +9,5 @@ Other features: * See map even behind your status bar and navigation bar! * More conveniently refresh the webpage when fullscreen. (During network congestion, Chrome HTTP/3 scheduler retries requests at its own pace. Doing a manual refresh overrides this.) -* Login button is clicked automatically. +* Login button is clicked automatically (Discord login only). * You could make alerts follow location in background. diff --git a/app/src/main/java/be/mygod/reactmap/MainActivity.kt b/app/src/main/java/be/mygod/reactmap/MainActivity.kt index 6027833..c53cc59 100644 --- a/app/src/main/java/be/mygod/reactmap/MainActivity.kt +++ b/app/src/main/java/be/mygod/reactmap/MainActivity.kt @@ -56,7 +56,7 @@ class MainActivity : FragmentActivity() { private const val URL_RELOADING = "data:text/html;charset=utf-8,%3Ctitle%3ELoading...%3C%2Ftitle%3E%3Ch1%20style%3D%22display%3Aflex%3Bjustify-content%3Acenter%3Balign-items%3Acenter%3Btext-align%3Acenter%3Bheight%3A100vh%22%3ELoading..." private val filenameExtractor = "filename=(\"([^\"]+)\"|[^;]+)".toRegex(RegexOption.IGNORE_CASE) - private val supportedHosts = setOf("discordapp.com", "discord.com") + private val supportedHosts = setOf("discordapp.com", "discord.com", "telegram.org", "oauth.telegram.org") } private lateinit var web: WebView