diff --git a/README.md b/README.md
index 2495795..28c82b7 100644
--- a/README.md
+++ b/README.md
@@ -9,4 +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/build.gradle.kts b/app/build.gradle.kts
index b898253..7ecc3d1 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 = 52
+ versionName = "0.5.1"
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..d27f5ad 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..8d54382 100644
--- a/app/src/main/java/be/mygod/reactmap/App.kt
+++ b/app/src/main/java/be/mygod/reactmap/App.kt
@@ -2,26 +2,57 @@ 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
+import android.os.UserManager
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
+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 userManager 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",
@@ -42,7 +73,25 @@ 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_SUCCESS, "Background location updated",
+ NotificationManager.IMPORTANCE_MIN),
+ 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..860e2b5
--- /dev/null
+++ b/app/src/main/java/be/mygod/reactmap/ConfigDialogFragment.kt
@@ -0,0 +1,93 @@
+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 be.mygod.reactmap.util.readableMessage
+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\n(beware that you would be sharing your location with the map)"
+ 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!!
+ }
+ val oldApiUrl = ReactMapHttpEngine.apiUrl
+ app.pref.edit {
+ putString(App.KEY_ACTIVE_URL, uri)
+ putStringSet(KEY_HISTORY_URL, historyUrl + uri)
+ }
+ if (oldApiUrl != ReactMapHttpEngine.apiUrl) BackgroundLocationReceiver.onApiChanged()
+ host
+ } catch (e: Exception) {
+ Toast.makeText(requireContext(), e.readableMessage, Toast.LENGTH_LONG).show()
+ null
+ }).also { BackgroundLocationReceiver.enabled = followerSwitch.isChecked }
+}
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..c53cc59 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,22 @@ 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.follower.BackgroundLocationReceiver
+import be.mygod.reactmap.util.AlertDialogFragment
+import be.mygod.reactmap.util.CreateDynamicDocument
+import be.mygod.reactmap.util.findErrorStream
+import be.mygod.reactmap.util.readableMessage
import com.google.firebase.analytics.FirebaseAnalytics
import kotlinx.coroutines.launch
import org.json.JSONArray
@@ -45,51 +41,27 @@ 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 val supportedHosts = setOf("discordapp.com", "discord.com", "telegram.org", "oauth.telegram.org")
}
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
private val windowInsetsController by lazy { WindowCompat.getInsetsController(window, web) }
private var loginText: String? = null
@@ -110,8 +82,8 @@ 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)!!
+ if (BuildConfig.DEBUG) WebView.setWebContentsDebuggingEnabled(true)
+ val activeUrl = app.activeUrl
hostname = Uri.parse(activeUrl).host!!
web = WebView(this).apply {
settings.apply {
@@ -176,10 +148,13 @@ 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()
+ 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 {
@@ -216,7 +191,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 +208,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 +235,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,42 +283,14 @@ 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 &&" +
"am start -n $packageName/com.nianticproject.holoholo.libholoholo.unity.UnityMainActivity").start()
} catch (e: Exception) {
Timber.w(e)
- Toast.makeText(this@MainActivity, e.localizedMessage ?: e.javaClass.name, Toast.LENGTH_LONG).show()
+ Toast.makeText(this@MainActivity, e.readableMessage, Toast.LENGTH_LONG).show()
}
}
}
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..efa6aca
--- /dev/null
+++ b/app/src/main/java/be/mygod/reactmap/ReactMapHttpEngine.kt
@@ -0,0 +1,58 @@
+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 androidx.core.content.edit
+import androidx.core.net.toUri
+import be.mygod.reactmap.App.Companion.app
+import java.io.File
+import java.net.HttpURLConnection
+import java.net.URL
+
+object ReactMapHttpEngine {
+ private const val KEY_COOKIE = "cookie.graphql"
+
+ @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()
+ }
+
+ val apiUrl get() = app.activeUrl.toUri().buildUpon().apply {
+ path("/graphql")
+ }.build().toString()
+
+ 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 {
+ if (app.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(apiUrl))
+ }
+}
diff --git a/app/src/main/java/be/mygod/reactmap/SiteController.kt b/app/src/main/java/be/mygod/reactmap/SiteController.kt
index 96e51aa..3415dda 100644
--- a/app/src/main/java/be/mygod/reactmap/SiteController.kt
+++ b/app/src/main/java/be/mygod/reactmap/SiteController.kt
@@ -1,37 +1,30 @@
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)
setContentTitle(title)
setContentText("Tap to configure")
+ setGroup(CHANNEL_ID)
setSmallIcon(R.drawable.ic_reactmap)
setOngoing(true)
priority = NotificationCompat.PRIORITY_LOW
@@ -39,7 +32,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 +52,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..98aa3bf
--- /dev/null
+++ b/app/src/main/java/be/mygod/reactmap/follower/BackgroundLocationReceiver.kt
@@ -0,0 +1,182 @@
+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.IntentFilter
+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.ReactMapHttpEngine
+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
+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 ACTION_LOCATION = "location"
+ const val MIN_UPDATE_THRESHOLD_METER = 20f
+
+ 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
+ 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 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
+ @MainThread
+ 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) {
+ 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)
+ }
+ @MainThread
+ fun stop() {
+ if (active) app.fusedLocation.removeLocationUpdates(locationPendingIntent).addOnCompleteListener { task ->
+ if (task.isSuccessful) {
+ Timber.d("BackgroundLocationReceiver stopped")
+ active = false
+ } else Timber.w(task.exception)
+ }
+ }
+
+ @MainThread
+ 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)
+ 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,
+ LocationSetter.KEY_TIME to location.time,
+ LocationSetter.KEY_API_URL to ReactMapHttpEngine.apiUrl))
+ }.build())
+ }
+
+ override fun onReceive(context: Context, intent: Intent) {
+ when (intent.action) {
+ 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
+ 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 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
+ }
+ 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) enqueueSubmission(bestLocation)
+ }
+ }
+ }
+}
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..2458aed
--- /dev/null
+++ b/app/src/main/java/be/mygod/reactmap/follower/LocationSetter.kt
@@ -0,0 +1,173 @@
+package be.mygod.reactmap.follower
+
+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
+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 be.mygod.reactmap.util.readableMessage
+import kotlinx.coroutines.CancellationException
+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
+
+class LocationSetter(appContext: Context, workerParams: WorkerParameters) : CoroutineWorker(appContext, workerParams) {
+ 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"
+ const val KEY_TIME = "time"
+ const val KEY_API_URL = "apiUrl"
+ private const val ID_STATUS = 3
+
+ fun notifyError(message: CharSequence) {
+ 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
+ setContentIntent(PendingIntent.getActivity(app, 2,
+ Intent(app, MainActivity::class.java).setAction(MainActivity.ACTION_CONFIGURE),
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE))
+ }.build())
+ }
+
+ private val secondFormat = DecimalFormat(".###")
+ private fun formatTimeSpanFrom(from: Long): String {
+ var t = (System.currentTimeMillis() - from) * .001
+ if (t < 60) return "${secondFormat.format(t)}s"
+ if (t < 60 * 60) {
+ val s = (t % 60).toInt()
+ if (s == 0) return "${t.toInt()}m"
+ 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 / 60).toInt()}h ${m}m"
+ }
+ t /= 60
+ val h = (t % 24).toInt()
+ if (h == 0) return "${t.toInt()}d"
+ return "${(t / 24).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 apiUrl = inputData.getString(KEY_API_URL)!!
+ val conn = ReactMapHttpEngine.openConnection(apiUrl) {
+ 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) {" +
+ "human { current_profile_no name type } } }")
+ }.toString())
+ }
+ }
+ when (val code = conn.responseCode) {
+ 200 -> {
+ 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.getLong("current_profile_no")
+ } catch (e: JSONException) {
+ throw Exception(response, e)
+ }
+ withContext(Dispatchers.Main) {
+ BackgroundLocationReceiver.onLocationSubmitted(apiUrl, 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")
+ setGroup(CHANNEL_ID)
+ 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 (stale ${formatTimeSpanFrom(time)}) > $human")
+ }.build())
+ Result.success()
+ }
+ else -> {
+ val error = conn.findErrorStream.bufferedReader().readText()
+ 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 {
+ Timber.w(Exception(error + code))
+ Result.retry()
+ }
+ }
+ }
+ } catch (e: IOException) {
+ Timber.d(e)
+ Result.retry()
+ } catch (_: CancellationException) {
+ Result.failure()
+ } catch (e: Exception) {
+ Timber.w(e)
+ notifyError(e.readableMessage)
+ Result.failure()
+ }
+
+ override suspend fun getForegroundInfo() = ForegroundInfo(2, NotificationCompat.Builder(app, CHANNEL_ID).apply {
+ 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) + " 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)
+ 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..870c32a
--- /dev/null
+++ b/app/src/main/java/be/mygod/reactmap/util/Utils.kt
@@ -0,0 +1,35 @@
+package be.mygod.reactmap.util
+
+import android.os.Parcel
+import android.os.Parcelable
+import android.os.RemoteException
+import androidx.core.os.ParcelCompat
+import java.lang.reflect.InvocationTargetException
+import java.net.HttpURLConnection
+
+tailrec fun Throwable.getRootCause(): Throwable {
+ if (this is InvocationTargetException || this is RemoteException) return (cause ?: return this).getRootCause()
+ return this
+}
+val Throwable.readableMessage: String get() = getRootCause().run { localizedMessage ?: javaClass.name }
+
+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..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"
@@ -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" }