diff --git a/LICENSE b/LICENSE index 261eeb9..45dd5c1 100644 --- a/LICENSE +++ b/LICENSE @@ -186,7 +186,7 @@ same "printed page" as the copyright notice for easier identification within third-party archives. - Copyright [yyyy] [name of copyright owner] + Copyright 2023 Mygod Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/README.md b/README.md index 28c82b7..77531c6 100644 --- a/README.md +++ b/README.md @@ -11,3 +11,48 @@ Other features: * 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 (Discord login only). * You could make alerts follow location in background. + +## Guide for custom build + +### Setup instructions + +1. If you have not already, generate a signing key with [`keytool`](https://developer.android.com/build/building-cmdline#sign_cmdline). + Using [RSA with a key length of 4096, 8192, or 16384 bits](https://github.com/google/bundletool/blob/0b9149c283e2df73850da670f2130a732639283d/src/main/java/com/android/tools/build/bundletool/commands/AddTransparencyCommand.java#L97) is recommended. +2. Create the following file at `https://mymap.com/.well-known/assetlinks.json` by creating the file in `/path/to/reactmap/public/.well-known/assetlinks.json`: + ```json + [ + { + "relation": [ + "delegate_permission/common.handle_all_urls" + ], + "target": { + "namespace": "android_app", + "package_name": "be.mygod.reactmap.com.mymap", + "sha256_cert_fingerprints": [ + "insert your sha256 cert fingerprint" + ] + } + } + ] + ``` + where the fingerprint can be obtained via `keytool -list -v -keystore /path/to/keystore`. + (If you prefer to using Android Studio to create this file with a wizard, see [here]( https://developer.android.com/studio/write/app-link-indexing#associatesite) for instructions.) +3. (Optional) Make modifications to the source code if you wish. + +### Build instructions + +Build with [injected properties](https://stackoverflow.com/a/47356720/2245107) (modifying these values as you wish): +``` +./gradlew assembleRelease \ + -Pandroid.injected.signing.store.file=$KEYFILE \ + -Pandroid.injected.signing.store.password=$STORE_PASSWORD \ + -Pandroid.injected.signing.key.alias=$KEY_ALIAS \ + -Pandroid.injected.signing.key.password=$KEY_PASSWORD \ + -Preactmap.defaultHost=mymap.com \ + -Preactmap.packageName=be.mygod.reactmap.com.mymap +``` + +Optionally you can also inject `reactmap.appName`. +An alternative to using `-P` switches is to adding your properties to the `gradle.properties` file in the root directory. + +Success! Find your apk in `app/build/outputs/apk/release`. diff --git a/app/build.gradle.kts b/app/build.gradle.kts index cb963c6..7dca602 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,7 +1,10 @@ +import com.google.firebase.crashlytics.buildtools.gradle.CrashlyticsExtension + plugins { alias(libs.plugins.androidApplication) alias(libs.plugins.firebaseCrashlytics) - alias(libs.plugins.googleServices) + // https://developers.google.com/android/guides/google-services-plugin#processing_the_json_file +// alias(libs.plugins.googleServices) alias(libs.plugins.kotlinAndroid) id("kotlin-parcelize") } @@ -11,13 +14,19 @@ android { compileSdk = 34 defaultConfig { - applicationId = "be.mygod.reactmap" + applicationId = extra["reactmap.packageName"] as String? minSdk = 26 targetSdk = 34 - versionCode = 53 - versionName = "0.5.2" + versionCode = 60 + versionName = "0.6.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + + if (extra.has("reactmap.appName")) resValue("string", "app_name", extra["reactmap.appName"] as String) + extra["reactmap.defaultDomain"]!!.let { defaultDomain -> + manifestPlaceholders["defaultDomain"] = defaultDomain + buildConfigField("String", "DEFAULT_DOMAIN", "\"$defaultDomain\"") + } } buildTypes { @@ -27,6 +36,9 @@ android { release { isMinifyEnabled = true proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") + if (!pluginManager.hasPlugin("com.google.gms.google-services")) { + the().mappingFileUploadEnabled = false + } } } buildFeatures.buildConfig = true diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index b9ce4a6..49fd066 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -2,12 +2,12 @@ - + - + @@ -36,6 +36,16 @@ + + + + + + + + + + - + @@ -52,55 +62,57 @@ + tools:replace="android:directBootAware" /> + tools:replace="android:directBootAware" /> + tools:node="merge" /> + + tools:replace="android:directBootAware" /> + tools:replace="android:directBootAware" /> + tools:replace="android:directBootAware" /> + tools:replace="android:directBootAware" /> + tools:replace="android:directBootAware" /> + tools:replace="android:directBootAware" /> + tools:replace="android:directBootAware" /> + tools:replace="android:directBootAware" /> + + tools:node="remove" /> + tools:node="remove" /> diff --git a/app/src/main/java/be/mygod/reactmap/App.kt b/app/src/main/java/be/mygod/reactmap/App.kt index 18a38e7..5557822 100644 --- a/app/src/main/java/be/mygod/reactmap/App.kt +++ b/app/src/main/java/be/mygod/reactmap/App.kt @@ -32,7 +32,6 @@ 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 } @@ -44,7 +43,7 @@ class App : Application() { val nm by lazy { getSystemService()!! } val userManager by lazy { getSystemService()!! } - val activeUrl get() = pref.getString(KEY_ACTIVE_URL, URL_DEFAULT) ?: URL_DEFAULT + val activeUrl get() = pref.getString(KEY_ACTIVE_URL, null) ?: "https://${BuildConfig.DEFAULT_DOMAIN}" override fun onCreate() { super.onCreate() @@ -79,13 +78,17 @@ class App : Application() { NotificationChannel(SiteController.CHANNEL_ID, "Full screen site controls", NotificationManager.IMPORTANCE_LOW).apply { lockscreenVisibility = Notification.VISIBILITY_SECRET + setShowBadge(false) }, NotificationChannel(LocationSetter.CHANNEL_ID, "Background location updating", NotificationManager.IMPORTANCE_LOW).apply { lockscreenVisibility = Notification.VISIBILITY_PUBLIC + setShowBadge(false) }, NotificationChannel(LocationSetter.CHANNEL_ID_SUCCESS, "Background location updated", - NotificationManager.IMPORTANCE_MIN), + NotificationManager.IMPORTANCE_MIN).apply { + setShowBadge(false) + }, NotificationChannel(LocationSetter.CHANNEL_ID_ERROR, "Background location update failed", NotificationManager.IMPORTANCE_HIGH).apply { enableLights(true) diff --git a/app/src/main/java/be/mygod/reactmap/ConfigDialogFragment.kt b/app/src/main/java/be/mygod/reactmap/ConfigDialogFragment.kt index 9dd0819..dfe5328 100644 --- a/app/src/main/java/be/mygod/reactmap/ConfigDialogFragment.kt +++ b/app/src/main/java/be/mygod/reactmap/ConfigDialogFragment.kt @@ -47,7 +47,7 @@ class ConfigDialogFragment : AlertDialogFragment(this) { which, _ -> if (which != DialogInterface.BUTTON_POSITIVE) return@setResultListener currentFragment?.terminate() - reactMapFragment() + reactMapFragment(null) } supportFragmentManager.setFragmentResultListener("ReactMapFragment", this) { _, _ -> - reactMapFragment() + reactMapFragment(null) } } + override fun onNewIntent(intent: Intent?) { + super.onNewIntent(intent) + handleIntent(intent) + } + private var currentFragment: ReactMapFragment? = null - private fun reactMapFragment() = supportFragmentManager.commit { - replace(R.id.content, ReactMapFragment().also { currentFragment = it }) + private fun reactMapFragment(overrideUri: Uri?) = supportFragmentManager.commit { + replace(R.id.content, ReactMapFragment(overrideUri).also { currentFragment = it }) } - override fun onNewIntent(intent: Intent?) { - super.onNewIntent(intent) + private fun handleIntent(intent: Intent?) { when (intent?.action) { ACTION_CONFIGURE -> startConfigure(false) ACTION_RESTART_GAME -> AlertDialog.Builder(this).apply { @@ -65,6 +71,10 @@ class MainActivity : FragmentActivity() { setNegativeButton("Samsung") { _, _ -> restartGame("com.nianticlabs.pokemongo.ares") } setNeutralButton(android.R.string.cancel, null) }.show() + Intent.ACTION_VIEW -> { + val currentFragment = currentFragment + if (currentFragment == null) reactMapFragment(intent.data) else currentFragment.handleUri(intent.data) + } } } private fun startConfigure(welcome: Boolean) = ConfigDialogFragment().apply { diff --git a/app/src/main/java/be/mygod/reactmap/webkit/ReactMapFragment.kt b/app/src/main/java/be/mygod/reactmap/webkit/ReactMapFragment.kt index 5ebf1a0..12dea84 100644 --- a/app/src/main/java/be/mygod/reactmap/webkit/ReactMapFragment.kt +++ b/app/src/main/java/be/mygod/reactmap/webkit/ReactMapFragment.kt @@ -29,6 +29,7 @@ import androidx.fragment.app.setFragmentResult import androidx.lifecycle.DefaultLifecycleObserver import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.withCreated import be.mygod.reactmap.App.Companion.app import be.mygod.reactmap.follower.BackgroundLocationReceiver import be.mygod.reactmap.util.CreateDynamicDocument @@ -47,9 +48,12 @@ import java.net.URLDecoder import java.nio.charset.Charset import java.util.Locale -class ReactMapFragment : Fragment() { +class ReactMapFragment @JvmOverloads constructor(private val overrideUri: Uri? = null) : Fragment() { companion object { private val filenameExtractor = "filename=(\"([^\"]+)\"|[^;]+)".toRegex(RegexOption.IGNORE_CASE) + private val vendorJsMatcher = "/vendor-[0-9a-f]{8}\\.js".toRegex() + private val flyToMatcher = "/@/([0-9.-]+)/([0-9.-]+)(?:/([0-9.-]+))?/?".toRegex() + private val mapHijacker = ",this.callInitHooks\\(\\),this._zoomAnimated=".toRegex() private val supportedHosts = setOf("discordapp.com", "discord.com", "telegram.org", "oauth.telegram.org") } @@ -76,8 +80,8 @@ class ReactMapFragment : Fragment() { override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View { Timber.d("Creating ReactMapFragment") - val activeUrl = app.activeUrl - hostname = Uri.parse(activeUrl).host!! + val activeUrl = overrideUri?.toString() ?: app.activeUrl + hostname = if (overrideUri == null) Uri.parse(activeUrl).host!! else overrideUri.host!! val activity = requireActivity() web = WebView(activity).apply { settings.apply { @@ -170,9 +174,10 @@ class ReactMapFragment : Fragment() { // Since CookieManager.getCookie does not return session cookie on main requests, // we can only edit secondary files "/api/settings" -> handleSettings(request) - else -> if (path?.startsWith("/locales/") == true && path.endsWith("/translation.json")) { + null -> null + else -> if (path.startsWith("/locales/") && path.endsWith("/translation.json")) { handleTranslation(request) - } else null + } else if (vendorJsMatcher.matchEntire(path) != null) handleVendorJs(request) else null } override fun onRenderProcessGone(view: WebView, detail: RenderProcessGoneDetail): Boolean { @@ -202,13 +207,13 @@ class ReactMapFragment : Fragment() { return web } - private fun buildResponse(request: WebResourceRequest, transform: (Reader) -> String): WebResourceResponse { + private fun buildResponse(request: WebResourceRequest, transform: (Reader) -> String) = try { val url = request.url.toString() val conn = ReactMapHttpEngine.openConnection(url) { requestMethod = request.method for ((key, value) in request.requestHeaders) addRequestProperty(key, value) } - return WebResourceResponse(conn.contentType?.substringBefore(';'), conn.contentEncoding, conn.responseCode, + WebResourceResponse(conn.contentType?.substringBefore(';'), conn.contentEncoding, conn.responseCode, conn.responseMessage.let { if (it.isNullOrBlank()) "N/A" else it }, conn.headerFields.mapValues { (_, value) -> value.joinToString() }, if (conn.responseCode in 200..299) try { @@ -220,6 +225,9 @@ class ReactMapFragment : Fragment() { Timber.d(e) conn.inputStream } else conn.findErrorStream) + } catch (e: IOException) { + Timber.d(e) + null } private fun handleSettings(request: WebResourceRequest) = buildResponse(request) { reader -> val response = reader.readText() @@ -253,12 +261,32 @@ class ReactMapFragment : Fragment() { } response } + private fun handleVendorJs(request: WebResourceRequest) = buildResponse(request) { reader -> + mapHijacker.replace(reader.readText(), ",(window._hijackedMap=this).callInitHooks(),this._zoomAnimated=") + } override fun onDestroyView() { super.onDestroyView() web.destroy() } + fun handleUri(uri: Uri?) = uri?.host?.let { host -> + viewLifecycleOwner.lifecycleScope.launch { + withCreated { } + Timber.d("Handling URI $uri") + if (host != hostname) { + hostname = host + return@launch web.loadUrl(uri.toString()) + } + val path = uri.path + if (path.isNullOrEmpty() || path == "/") return@launch + val match = flyToMatcher.matchEntire(path) ?: return@launch web.loadUrl(uri.toString()) + val script = StringBuilder("window._hijackedMap.flyTo([${match.groupValues[1]}, ${match.groupValues[2]}]") + match.groups[3]?.let { script.append(", ${it.value}") } + script.append(')') + web.evaluateJavascript(script.toString(), null) + } + } fun terminate() { if (Build.VERSION.SDK_INT >= 29 && web.webViewRenderProcess?.terminate() == false) Timber.w(Exception( "Termination failed")) diff --git a/app/src/main/java/be/mygod/reactmap/webkit/SiteController.kt b/app/src/main/java/be/mygod/reactmap/webkit/SiteController.kt index 759505d..c5a99fe 100644 --- a/app/src/main/java/be/mygod/reactmap/webkit/SiteController.kt +++ b/app/src/main/java/be/mygod/reactmap/webkit/SiteController.kt @@ -7,6 +7,7 @@ import android.graphics.drawable.Icon import androidx.activity.result.contract.ActivityResultContracts import androidx.fragment.app.Fragment import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.Lifecycle import androidx.lifecycle.LifecycleOwner import be.mygod.reactmap.App.Companion.app import be.mygod.reactmap.MainActivity @@ -21,12 +22,12 @@ class SiteController(private val fragment: Fragment) : DefaultLifecycleObserver } private val requestPermission = fragment.registerForActivityResult(ActivityResultContracts.RequestPermission()) { - if (!it || !started) return@registerForActivityResult + if (!it || !fragment.lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)) return@registerForActivityResult val context = fragment.requireContext() app.nm.notify(1, Notification.Builder(context, CHANNEL_ID).apply { setWhen(0) setCategory(Notification.CATEGORY_SERVICE) - setContentTitle(title) + setContentTitle(title ?: "Loading…") setContentText("Tap to configure") setColor(context.getColor(R.color.main_blue)) setGroup(CHANNEL_ID) @@ -42,21 +43,16 @@ class SiteController(private val fragment: Fragment) : DefaultLifecycleObserver PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE)).build()) }.build()) } - private var started = false var title: String? = null set(value) { field = value - if (started) requestPermission.launch(android.Manifest.permission.POST_NOTIFICATIONS) + if (fragment.lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)) { + requestPermission.launch(android.Manifest.permission.POST_NOTIFICATIONS) + } } - override fun onStart(owner: LifecycleOwner) { - started = true - if (title != null) requestPermission.launch(android.Manifest.permission.POST_NOTIFICATIONS) - } - - override fun onStop(owner: LifecycleOwner) { - started = false - app.nm.cancel(1) - } + override fun onStart(owner: LifecycleOwner) = + requestPermission.launch(android.Manifest.permission.POST_NOTIFICATIONS) + override fun onStop(owner: LifecycleOwner) = app.nm.cancel(1) } diff --git a/app/src/main/res/values/google-services.xml b/app/src/main/res/values/google-services.xml new file mode 100644 index 0000000..f9feea1 --- /dev/null +++ b/app/src/main/res/values/google-services.xml @@ -0,0 +1,10 @@ + + + 53628743953-u05270s2od1es37b9pdrvmn10q6aa3iu.apps.googleusercontent.com + 53628743953 + AIzaSyDlBEXzi1_Kf0kY6cdxexhHfuxyrxx5ZB0 + 1:53628743953:android:ef886035becf4f5adb1cb0 + AIzaSyDlBEXzi1_Kf0kY6cdxexhHfuxyrxx5ZB0 + reactmap-android.appspot.com + reactmap-android + diff --git a/gradle.properties b/gradle.properties index c3e0905..d95f425 100644 --- a/gradle.properties +++ b/gradle.properties @@ -23,3 +23,7 @@ kotlin.code.style=official android.nonTransitiveRClass=true android.enableResourceOptimizations=false android.enableVcsInfo=true + +# ReactMap settings for customized build +reactmap.defaultDomain=www.reactmap.dev +reactmap.packageName=be.mygod.reactmap diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index e6bb32f..b1eb174 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,6 +1,6 @@ [versions] activity = "1.8.0-rc01" -agp = "8.3.0-alpha05" +agp = "8.3.0-alpha06" androidx-test-ext-junit = "1.1.5" browser = "1.6.0" core = "1.12.0"