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" }