diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 01efe14..7384013 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -60,6 +60,7 @@ android {
dependencies {
coreLibraryDesugaring(libs.desugar)
+ implementation(fileTree(mapOf("dir" to "libs", "include" to listOf("*.jar", "*.aar"))))
implementation(platform(libs.firebase.bom))
implementation(libs.activity)
implementation(libs.browser)
diff --git a/app/libs/aa_sdk_v105 b/app/libs/aa_sdk_v105
new file mode 100644
index 0000000..e69de29
diff --git a/app/libs/android-support-car.aar b/app/libs/android-support-car.aar
new file mode 100755
index 0000000..e7187e5
Binary files /dev/null and b/app/libs/android-support-car.aar differ
diff --git a/app/libs/gearhead-sdk.aar b/app/libs/gearhead-sdk.aar
new file mode 100755
index 0000000..6074549
Binary files /dev/null and b/app/libs/gearhead-sdk.aar differ
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index 899b5b5..89ff285 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -8,6 +8,7 @@
+
@@ -60,6 +61,16 @@
+
+
+
+
+
+
+
+
+
+
(this) { which, _ ->
if (which != DialogInterface.BUTTON_POSITIVE) return@setResultListener
currentFragment?.terminate()
- try {
- for (file in File("/proc/self/fd").listFiles() ?: emptyArray()) try {
- val fdInt = file.name.toInt()
- val fd = FileDescriptor().apply { setInt(this, fdInt) }
- val endpoint = try {
- Os.getsockname(fd)
- } catch (e: ErrnoException) {
- if (e.errno == OsConstants.EBADF || e.errno == OsConstants.ENOTSOCK) continue else throw e
- }
- if (endpoint !is InetSocketAddress) continue
- val isTcp = when (val type = UnblockCentral.getsockoptInt(null, fd, OsConstants.SOL_SOCKET,
- OsConstants.SO_TYPE)) {
- OsConstants.SOCK_STREAM -> true
- OsConstants.SOCK_DGRAM -> false
- else -> {
- Timber.w(Exception("Unknown $type to $endpoint"))
- continue
- }
- }
- val ownerTag = if (Build.VERSION.SDK_INT >= 29) try {
- UnblockCentral.fdsanGetOwnerTag(os, fd) as Long
- } catch (e: Exception) {
- Timber.w(e)
- 0
- } else 0
- Timber.d("Resetting $fdInt owned by $ownerTag if is 0 -> $endpoint $isTcp")
- if (ownerTag != 0L) continue
- if (isTcp) {
- UnblockCentral.setsockoptLinger(null, fd, OsConstants.SOL_SOCKET,
- OsConstants.SO_LINGER, UnblockCentral.lingerReset)
- } else Os.dup2(nullFd, fdInt)
- } catch (e: Exception) {
- Timber.w(e)
- }
- } catch (e: IOException) {
- Timber.d(e)
- }
reactMapFragment()
}
supportFragmentManager.setFragmentResultListener("ReactMapFragment", this) { _, _ ->
diff --git a/app/src/main/java/be/mygod/reactmap/auto/CarKeyboard.kt b/app/src/main/java/be/mygod/reactmap/auto/CarKeyboard.kt
new file mode 100644
index 0000000..499915e
--- /dev/null
+++ b/app/src/main/java/be/mygod/reactmap/auto/CarKeyboard.kt
@@ -0,0 +1,62 @@
+package be.mygod.reactmap.auto
+
+import android.webkit.JavascriptInterface
+import android.webkit.WebView
+import androidx.lifecycle.LifecycleOwner
+import androidx.lifecycle.lifecycleScope
+import be.mygod.reactmap.R
+import com.google.android.apps.auto.sdk.CarActivity
+import com.google.android.apps.auto.sdk.SearchCallback
+import com.google.android.apps.auto.sdk.SearchItem
+import kotlinx.coroutines.launch
+import org.json.JSONStringer
+
+class CarKeyboard(private val web: WebView, private val lifecycleOwner: LifecycleOwner) : SearchCallback() {
+ companion object {
+ private val string by lazy {
+ JSONStringer::class.java.getDeclaredMethod("string", String::class.java).apply { isAccessible = true }
+ }
+ }
+
+ private val activity = (web.context as CarActivity).also {
+ it.carUiController.searchController.setSearchCallback(this)
+ web.addJavascriptInterface(this, "_autoKeyboard")
+ }
+ private val jsSetup = activity.resources.openRawResource(R.raw.setup_keyboard).bufferedReader().readText()
+ fun setup() {
+ isActive = false
+ web.evaluateJavascript(jsSetup, null)
+ }
+
+ private var isActive = false
+
+ @JavascriptInterface
+ fun request(text: String?, hint: CharSequence?): Boolean {
+ if (isActive) return false
+ isActive = true
+ lifecycleOwner.lifecycleScope.launch {
+ activity.carUiController.searchController.setSearchHint(hint)
+// searchController.setSearchItems()
+ activity.carUiController.searchController.startSearch(text)
+ }
+ return true
+ }
+
+ override fun onSearchTextChanged(searchTerm: String?) { }
+
+ override fun onSearchSubmitted(searchTerm: String?) = true.also {
+ isActive = false
+ val value = searchTerm?.let {
+ JSONStringer().apply { string(this, it) }.toString()
+ }.toString()
+ web.evaluateJavascript("window._autoKeyboardCallback.valueReady($value)", null)
+ }
+
+ override fun onSearchItemSelected(searchItem: SearchItem?) { }
+
+ override fun onSearchStop() {
+ if (!isActive) return
+ isActive = false
+ web.evaluateJavascript("window._autoKeyboardCallback.dismiss()", null)
+ }
+}
diff --git a/app/src/main/java/be/mygod/reactmap/auto/CarReactMapFragment.kt b/app/src/main/java/be/mygod/reactmap/auto/CarReactMapFragment.kt
new file mode 100644
index 0000000..620c8c1
--- /dev/null
+++ b/app/src/main/java/be/mygod/reactmap/auto/CarReactMapFragment.kt
@@ -0,0 +1,75 @@
+package be.mygod.reactmap.auto
+
+import android.Manifest
+import android.content.Intent
+import android.content.pm.PackageManager
+import android.net.Uri
+import android.os.Bundle
+import android.view.View
+import android.webkit.JsResult
+import android.webkit.ValueCallback
+import android.webkit.WebChromeClient
+import be.mygod.reactmap.App.Companion.app
+import be.mygod.reactmap.R
+import be.mygod.reactmap.webkit.BaseReactMapFragment
+import com.google.android.material.snackbar.Snackbar
+import timber.log.Timber
+
+class CarReactMapFragment : BaseReactMapFragment() {
+ private val mainActivity by lazy { context as MainCarActivity }
+ private lateinit var carKeyboard: CarKeyboard
+ private lateinit var siteController: CarSiteController
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+ carKeyboard = CarKeyboard(web, this)
+ siteController = CarSiteController(this)
+ }
+
+ override fun onPageStarted() = carKeyboard.setup()
+
+ override fun onShowFileChooser(filePathCallback: ValueCallback>?,
+ fileChooserParams: WebChromeClient.FileChooserParams) {
+ Snackbar.make(web, R.string.car_toast_complicated_action, Snackbar.LENGTH_SHORT).show()
+ mainActivity.killMap()
+ }
+ override fun onDownloadStart(url: String?, userAgent: String?, contentDisposition: String?, mimetype: String?,
+ contentLength: Long) =
+ Snackbar.make(web, R.string.car_toast_complicated_action, Snackbar.LENGTH_SHORT).show()
+
+ override fun onReceiveTitle(title: String?) {
+ siteController.title = title
+ }
+
+ override fun onRenderProcessGone() = mainActivity.killMap()
+
+ override fun requestLocationPermissions() = requireContext().checkSelfPermission(
+ Manifest.permission.ACCESS_COARSE_LOCATION) == PackageManager.PERMISSION_GRANTED
+
+ override fun onUnsupportedUri(uri: Uri) {
+ val s = uri.toString()
+ if (s.startsWith("https://maps.google.com/maps/place/")) {
+ return mainActivity.startCarActivity(Intent(Intent.ACTION_VIEW,
+ Uri.parse("google.navigation:q=${s.substring(35)}")))
+ }
+ // support ACTION_DIAL tel:URL or starting other car apps?
+ try {
+ app.startActivity(Intent(app.customTabsIntent.intent).apply {
+ data = uri
+ addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ }, app.customTabsIntent.startAnimationBundle)
+ } catch (e: RuntimeException) {
+ Timber.d(e)
+ return Snackbar.make(web, s, Snackbar.LENGTH_SHORT).show()
+ }
+ Snackbar.make(web, R.string.car_toast_unsupported_url, Snackbar.LENGTH_SHORT).show()
+ }
+
+ override fun onJsAlert(message: String?, result: JsResult) = true.also {
+ Snackbar.make(web, message.toString(), Snackbar.LENGTH_INDEFINITE).apply {
+ addCallback(object : Snackbar.Callback() {
+ override fun onDismissed(transientBottomBar: Snackbar?, event: Int) = result.cancel()
+ })
+ }.show()
+ }
+}
diff --git a/app/src/main/java/be/mygod/reactmap/auto/CarSiteController.kt b/app/src/main/java/be/mygod/reactmap/auto/CarSiteController.kt
new file mode 100644
index 0000000..70b811e
--- /dev/null
+++ b/app/src/main/java/be/mygod/reactmap/auto/CarSiteController.kt
@@ -0,0 +1,47 @@
+package be.mygod.reactmap.auto
+
+import android.app.Notification
+import android.app.PendingIntent
+import android.content.Intent
+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.R
+
+class CarSiteController(private val fragment: Fragment) : DefaultLifecycleObserver {
+ companion object {
+ const val CHANNEL_ID = "carcontrol"
+ private const val NOTIFICATION_ID = 2
+ }
+ init {
+ fragment.lifecycle.addObserver(this)
+ }
+
+ private fun postNotification() = fragment.requireContext().let { context ->
+ app.nm.notify(NOTIFICATION_ID, Notification.Builder(context, CHANNEL_ID).apply {
+ setWhen(0)
+ setCategory(Notification.CATEGORY_SERVICE)
+ setContentTitle(title ?: context.getText(R.string.title_loading))
+ setContentText(context.getText(R.string.notification_car_site_controller_message))
+ setColor(context.getColor(R.color.main_blue))
+ setGroup(CHANNEL_ID)
+ setSmallIcon(R.drawable.ic_maps_directions_car)
+ setOngoing(true)
+ setVisibility(Notification.VISIBILITY_SECRET)
+ setContentIntent(PendingIntent.getBroadcast(context, 0,
+ Intent(MainCarActivity.ACTION_CLOSE).setPackage(context.packageName),
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE))
+ }.build())
+ }
+
+ var title: CharSequence? = null
+ set(value) {
+ field = value
+ if (fragment.lifecycle.currentState.isAtLeast(Lifecycle.State.STARTED)) postNotification()
+ }
+
+ override fun onStart(owner: LifecycleOwner) = postNotification()
+ override fun onStop(owner: LifecycleOwner) = app.nm.cancel(NOTIFICATION_ID)
+}
diff --git a/app/src/main/java/be/mygod/reactmap/auto/MainCarActivity.kt b/app/src/main/java/be/mygod/reactmap/auto/MainCarActivity.kt
new file mode 100644
index 0000000..0ac0214
--- /dev/null
+++ b/app/src/main/java/be/mygod/reactmap/auto/MainCarActivity.kt
@@ -0,0 +1,110 @@
+package be.mygod.reactmap.auto
+
+import android.annotation.SuppressLint
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import android.content.IntentFilter
+import android.os.Bundle
+import android.support.car.Car
+import android.support.car.CarConnectionCallback
+import android.support.car.CarInfoManager
+import android.text.method.ScrollingMovementMethod
+import android.view.Gravity
+import android.widget.Button
+import android.widget.LinearLayout
+import android.widget.TextView
+import androidx.annotation.Keep
+import androidx.core.content.ContextCompat
+import androidx.fragment.app.commit
+import androidx.lifecycle.DefaultLifecycleObserver
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.LifecycleOwner
+import androidx.lifecycle.LifecycleRegistry
+import androidx.lifecycle.lifecycleScope
+import androidx.lifecycle.repeatOnLifecycle
+import be.mygod.reactmap.R
+import be.mygod.reactmap.util.UpdateChecker
+import be.mygod.reactmap.util.format
+import com.google.android.apps.auto.sdk.CarActivity
+import kotlinx.coroutines.launch
+
+class MainCarActivity @Keep constructor() : CarActivity(), LifecycleOwner {
+ companion object {
+ const val ACTION_CLOSE = "be.mygod.reactmap.auto.action.CLOSE"
+ }
+ override val lifecycle = LifecycleRegistry(this)
+ private var currentFragment: CarReactMapFragment? = null
+
+ override fun onCreate(bundle: Bundle?) {
+ super.onCreate(bundle)
+ lifecycle.currentState = Lifecycle.State.CREATED
+ setContentView(R.layout.layout_car)
+ (findViewById(R.id.car_agreement) as TextView).apply {
+ text = getText(R.string.car_agreement_template).format(resources.configuration.locales[0],
+ getText(R.string.app_name))
+ movementMethod = ScrollingMovementMethod()
+ }
+ val proceed = (findViewById(R.id.proceed) as Button).apply {
+ setOnClickListener {
+ supportFragmentManager.commit {
+ replace(R.id.content, CarReactMapFragment().also { currentFragment = it })
+ }
+ }
+ }
+ Car.createCar(this, object : CarConnectionCallback() {
+ override fun onConnected(car: Car) {
+ val isDriverRight = (car.getCarManager(CarInfoManager::class.java) as CarInfoManager).driverPosition ==
+ CarInfoManager.DRIVER_SIDE_RIGHT
+ @SuppressLint("RtlHardcoded")
+ (proceed.layoutParams as LinearLayout.LayoutParams).gravity =
+ if (isDriverRight) Gravity.LEFT else Gravity.RIGHT // make the button away from driver's seat
+ proceed.requestLayout()
+ }
+ override fun onDisconnected(car: Car?) { }
+ }).apply {
+ connect()
+ lifecycle.addObserver(object : DefaultLifecycleObserver {
+ override fun onDestroy(owner: LifecycleOwner) = disconnect()
+ })
+ }
+ object : BroadcastReceiver() {
+ override fun onReceive(context: Context?, intent: Intent?) = killMap()
+ }.let {
+ ContextCompat.registerReceiver(this, it, IntentFilter(ACTION_CLOSE),
+ ContextCompat.RECEIVER_NOT_EXPORTED)
+ lifecycle.addObserver(object : DefaultLifecycleObserver {
+ override fun onDestroy(owner: LifecycleOwner) = unregisterReceiver(it)
+ })
+ }
+ lifecycleScope.launch { repeatOnLifecycle(Lifecycle.State.STARTED) { UpdateChecker.check() } }
+ }
+
+ fun killMap() {
+ val currentFragment = currentFragment ?: return
+ currentFragment.terminate()
+ supportFragmentManager.commit { remove(currentFragment) }
+ this.currentFragment = null
+ }
+
+ override fun onStart() {
+ super.onStart()
+ lifecycle.currentState = Lifecycle.State.STARTED
+ }
+ override fun onResume() {
+ super.onResume()
+ lifecycle.currentState = Lifecycle.State.RESUMED
+ }
+ override fun onPause() {
+ super.onPause()
+ lifecycle.currentState = Lifecycle.State.STARTED
+ }
+ override fun onStop() {
+ super.onStop()
+ lifecycle.currentState = Lifecycle.State.CREATED
+ }
+ override fun onDestroy() {
+ super.onDestroy()
+ lifecycle.currentState = Lifecycle.State.DESTROYED
+ }
+}
diff --git a/app/src/main/java/be/mygod/reactmap/auto/MainService.kt b/app/src/main/java/be/mygod/reactmap/auto/MainService.kt
new file mode 100644
index 0000000..f58ce06
--- /dev/null
+++ b/app/src/main/java/be/mygod/reactmap/auto/MainService.kt
@@ -0,0 +1,8 @@
+package be.mygod.reactmap.auto
+
+import androidx.annotation.Keep
+import com.google.android.apps.auto.sdk.CarActivityService
+
+class MainService @Keep constructor() : CarActivityService() {
+ override fun getCarActivity() = MainCarActivity::class.java
+}
diff --git a/app/src/main/java/be/mygod/reactmap/util/UpdateChecker.kt b/app/src/main/java/be/mygod/reactmap/util/UpdateChecker.kt
index 840da54..a51b89e 100644
--- a/app/src/main/java/be/mygod/reactmap/util/UpdateChecker.kt
+++ b/app/src/main/java/be/mygod/reactmap/util/UpdateChecker.kt
@@ -123,7 +123,8 @@ object UpdateChecker {
setShowWhen(true)
setContentIntent(PendingIntent.getActivity(app, 3,
app.customTabsIntent.intent.setData(Uri.parse(update.url.substringBefore("/tag/"))),
- PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE))
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
+ app.customTabsIntent.startAnimationBundle))
}.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
index 870c32a..0338ee0 100644
--- a/app/src/main/java/be/mygod/reactmap/util/Utils.kt
+++ b/app/src/main/java/be/mygod/reactmap/util/Utils.kt
@@ -3,9 +3,12 @@ package be.mygod.reactmap.util
import android.os.Parcel
import android.os.Parcelable
import android.os.RemoteException
+import android.text.SpannableStringBuilder
+import android.text.Spanned
import androidx.core.os.ParcelCompat
import java.lang.reflect.InvocationTargetException
import java.net.HttpURLConnection
+import java.util.Locale
tailrec fun Throwable.getRootCause(): Throwable {
if (this is InvocationTargetException || this is RemoteException) return (cause ?: return this).getRootCause()
@@ -33,3 +36,49 @@ inline fun ByteArray.toParcelable(classLoader: ClassLoa
}
val HttpURLConnection.findErrorStream get() = errorStream ?: inputStream
+
+private val formatSequence = "%([0-9]+\\$|)([^a-zA-z%]*)([[a-zA-Z%]&&[^tT]]|[tT][a-zA-Z])".toPattern()
+/**
+ * Version of [String.format] that works on [Spanned] strings to preserve rich text formatting.
+ * Both the `format` as well as any `%s args` can be Spanned and will have their formatting preserved.
+ * Due to the way [Spannable]s work, any argument's spans will can only be included **once** in the result.
+ * Any duplicates will appear as text only.
+ *
+ * See also: https://github.com/george-steel/android-utils/blob/289aff11e53593a55d780f9f5986e49343a79e55/src/org/oshkimaadziig/george/androidutils/SpanFormatter.java
+ *
+ * @param locale
+ * the locale to apply; `null` value means no localization.
+ * @param args
+ * the list of arguments passed to the formatter.
+ * @return the formatted string (with spans).
+ * @see String.format
+ * @author George T. Steel
+ */
+fun CharSequence.format(locale: Locale, vararg args: Any) = SpannableStringBuilder(this).apply {
+ var i = 0
+ var argAt = -1
+ while (i < length) {
+ val m = formatSequence.matcher(this)
+ if (!m.find(i)) break
+ i = m.start()
+ val exprEnd = m.end()
+ val argTerm = m.group(1)!!
+ val modTerm = m.group(2)
+ val cookedArg = when (val typeTerm = m.group(3)) {
+ "%" -> "%"
+ "n" -> "\n"
+ else -> {
+ val argItem = args[when (argTerm) {
+ "" -> ++argAt
+ "<" -> argAt
+ else -> Integer.parseInt(argTerm.substring(0, argTerm.length - 1)) - 1
+ }]
+ if (typeTerm == "s" && argItem is Spanned) argItem else {
+ String.format(locale, "%$modTerm$typeTerm", argItem)
+ }
+ }
+ }
+ replace(i, exprEnd, cookedArg)
+ i += cookedArg.length
+ }
+}
diff --git a/app/src/main/java/be/mygod/reactmap/webkit/BaseReactMapFragment.kt b/app/src/main/java/be/mygod/reactmap/webkit/BaseReactMapFragment.kt
new file mode 100644
index 0000000..63ddb97
--- /dev/null
+++ b/app/src/main/java/be/mygod/reactmap/webkit/BaseReactMapFragment.kt
@@ -0,0 +1,395 @@
+package be.mygod.reactmap.webkit
+
+import android.annotation.SuppressLint
+import android.graphics.Bitmap
+import android.net.Uri
+import android.os.Build
+import android.os.Bundle
+import android.system.ErrnoException
+import android.system.Os
+import android.system.OsConstants
+import android.util.JsonWriter
+import android.util.Log
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.webkit.ConsoleMessage
+import android.webkit.DownloadListener
+import android.webkit.JsResult
+import android.webkit.RenderProcessGoneDetail
+import android.webkit.ValueCallback
+import android.webkit.WebChromeClient
+import android.webkit.WebChromeClient.FileChooserParams
+import android.webkit.WebResourceError
+import android.webkit.WebResourceRequest
+import android.webkit.WebResourceResponse
+import android.webkit.WebView
+import android.webkit.WebViewClient
+import androidx.annotation.RequiresApi
+import androidx.core.net.toUri
+import androidx.core.os.bundleOf
+import androidx.fragment.app.Fragment
+import be.mygod.reactmap.App.Companion.app
+import be.mygod.reactmap.R
+import be.mygod.reactmap.follower.BackgroundLocationReceiver
+import be.mygod.reactmap.util.UnblockCentral
+import be.mygod.reactmap.util.findErrorStream
+import com.google.android.material.snackbar.Snackbar
+import com.google.firebase.analytics.FirebaseAnalytics
+import org.json.JSONArray
+import org.json.JSONException
+import org.json.JSONObject
+import timber.log.Timber
+import java.io.File
+import java.io.FileDescriptor
+import java.io.IOException
+import java.io.InputStream
+import java.io.Reader
+import java.io.StringWriter
+import java.net.HttpURLConnection
+import java.net.InetSocketAddress
+import java.net.URL
+import java.nio.charset.Charset
+import java.util.Locale
+import java.util.regex.Matcher
+
+abstract class BaseReactMapFragment : Fragment(), DownloadListener {
+ companion object {
+ // https://github.com/rollup/rollup/blob/10bdaa325a94ca632ef052e929a3e256dc1c7ade/docs/configuration-options/index.md?plain=1#L876
+ private val vendorJsMatcher = "/vendor-[0-9a-z_-]{8}\\.js".toRegex(RegexOption.IGNORE_CASE)
+
+ /**
+ * Raw regex: ([,}][\n\r\s]*)this(?=\.callInitHooks\(\)[,;][\n\r\s]*this\._zoomAnimated\s*=)
+ * if (options.center && options.zoom !== void 0) {
+ * this.setView(toLatLng(options.center), options.zoom, { reset: true });
+ * }
+ * this.callInitHooks();
+ * this._zoomAnimated = TRANSITION && any3d && !mobileOpera && this.options.zoomAnimation;
+ * or match minimized fragment: ",this.callInitHooks(),this._zoomAnimated="
+ */
+ private val injectMapInitialize = "([,}][\\n\\r\\s]*)this(?=\\.callInitHooks\\(\\)[,;][\\n\\r\\s]*this\\._zoomAnimated\\s*=)"
+ .toPattern()
+ /**
+ * Raw regex: ([;}][\n\r\s]*this\._stop\(\);)(?=[\n\r\s]*var )
+ * if (options.animate === false || !any3d) {
+ * return this.setView(targetCenter, targetZoom, options);
+ * }
+ * this._stop();
+ * var from = this.project(this.getCenter()), to = this.project(targetCenter), size = this.getSize(), startZoom = this._zoom;
+ * or match minimized fragment: ";this._stop();var "
+ */
+ private val injectMapFlyTo = "([;}][\\n\\r\\s]*this\\._stop\\(\\);)(?=[\\n\\r\\s]*var )".toPattern()
+ /**
+ * Raw regex: ([,;][\n\r\s]*this\._map\.on\("locationfound",\s*this\._onLocationFound,\s*)(?=this\)[,;])
+ * this._active = true;
+ * this._map.on("locationfound", this._onLocationFound, this);
+ * this._map.on("locationerror", this._onLocationError, this);
+ * or match minimized fragment: ",this._map.on("locationfound",this._onLocationFound,this),"
+ */
+ private val injectLocateControlActivate = "([,;][\\n\\r\\s]*this\\._map\\.on\\(\"locationfound\",\\s*this\\._onLocationFound,\\s*)(?=this\\)[,;])"
+ .toPattern()
+
+ private val supportedHosts = setOf("discordapp.com", "discord.com", "telegram.org", "oauth.telegram.org")
+ private val mediaExtensions = setOf(
+ "apng", "png", "avif", "gif", "jpg", "jpeg", "jfif", "pjpeg", "pjp", "png", "svg", "webp", "bmp", "ico", "cur",
+ "wav", "mp3", "mp4", "aac", "ogg", "flac",
+ "css", "js",
+ "ttf", "otf", "woff", "woff2",
+ )
+ private val mediaAcceptMatcher = "image/.*|text/css(?:[,;].*)?".toRegex(RegexOption.IGNORE_CASE)
+
+ private val newWebResourceResponse by lazy {
+ WebResourceResponse::class.java.getDeclaredConstructor(Boolean::class.java, String::class.java,
+ String::class.java, Int::class.java, String::class.java, Map::class.java, InputStream::class.java)
+ }
+
+ private val setInt by lazy { FileDescriptor::class.java.getDeclaredMethod("setInt$", Int::class.java) }
+ @get:RequiresApi(29)
+ private val os by lazy { Class.forName("libcore.io.Libcore").getDeclaredField("os").get(null) }
+ private val nullFd by lazy { Os.open("/dev/null", OsConstants.O_RDONLY, 0) }
+ }
+
+ protected lateinit var web: WebView
+ protected lateinit var glocation: Glocation
+ protected lateinit var hostname: String
+
+ private var loginText: String? = null
+ protected var loaded = false
+ private set
+
+ protected abstract fun onShowFileChooser(filePathCallback: ValueCallback>?,
+ fileChooserParams: FileChooserParams)
+ protected abstract fun onReceiveTitle(title: String?)
+ protected abstract fun onRenderProcessGone()
+ abstract fun requestLocationPermissions(): Boolean?
+
+ protected open fun onHistoryUpdated() { }
+ protected open fun onPageStarted() { }
+ protected open fun onPageFinished() { }
+ protected open fun findActiveUrl() = app.activeUrl.also { hostname = Uri.parse(it).host!! }
+ protected open fun onConfigAvailable(config: JSONObject) { }
+ protected open fun onUnsupportedUri(uri: Uri) = app.launchUrl(requireContext(), uri)
+ protected open fun onJsAlert(message: String?, result: JsResult) = false
+
+ override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
+ Timber.d("Creating ReactMapFragment")
+ web = WebView(requireContext()).apply {
+ settings.apply {
+ domStorageEnabled = true
+ @SuppressLint("SetJavaScriptEnabled")
+ javaScriptEnabled = true
+ }
+ glocation = Glocation(this, this@BaseReactMapFragment)
+ webChromeClient = object : WebChromeClient() {
+ @Suppress("KotlinConstantConditions")
+ override fun onConsoleMessage(consoleMessage: ConsoleMessage) = consoleMessage.run {
+ Timber.tag("WebConsole").log(when (messageLevel()) {
+ ConsoleMessage.MessageLevel.TIP -> Log.INFO
+ ConsoleMessage.MessageLevel.LOG -> Log.VERBOSE
+ ConsoleMessage.MessageLevel.WARNING -> Log.WARN
+ ConsoleMessage.MessageLevel.ERROR -> Log.ERROR
+ ConsoleMessage.MessageLevel.DEBUG -> Log.DEBUG
+ else -> error(messageLevel())
+ }, "${sourceId()}:${lineNumber()} - ${message()}")
+ true
+ }
+
+ override fun onReceivedTitle(view: WebView?, title: String?) = onReceiveTitle(title)
+ override fun onJsAlert(view: WebView?, url: String?, message: String?, result: JsResult) =
+ onJsAlert(message, result)
+
+ override fun onShowFileChooser(webView: WebView, filePathCallback: ValueCallback>?,
+ fileChooserParams: FileChooserParams) = true.also {
+ onShowFileChooser(filePathCallback, fileChooserParams)
+ }
+ }
+ val muiMargin = ReactMapMuiMarginListener(this)
+ webViewClient = object : WebViewClient() {
+ override fun doUpdateVisitedHistory(view: WebView?, url: String, isReload: Boolean) {
+ onHistoryUpdated()
+ if (url.toUri().path?.trimEnd('/') == "/login") loginText?.let { login ->
+ val writer = StringWriter()
+ writer.write("document.location = document.evaluate('//a[text()=")
+ JsonWriter(writer).use {
+ it.isLenient = true
+ it.value(login)
+ }
+ writer.write(
+ "]', document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue.href;")
+ web.evaluateJavascript(writer.toString(), null)
+ }
+ }
+
+ override fun onPageStarted(view: WebView, url: String, favicon: Bitmap?) {
+ glocation.clear()
+ if (url.toUri().host == hostname) glocation.setupGeolocation()
+ onPageStarted()
+ }
+
+ override fun onPageFinished(view: WebView?, url: String) {
+ onPageFinished()
+ 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 {
+ val parsed = request.url
+ return when {
+ parsed.host?.lowercase(Locale.ENGLISH).let { it != hostname && it !in supportedHosts } -> {
+ onUnsupportedUri(parsed)
+ true
+ }
+ "http".equals(parsed.scheme, true) -> {
+ Snackbar.make(view, R.string.error_https_only, Snackbar.LENGTH_SHORT).show()
+ true
+ }
+ else -> false
+ }
+ }
+
+ override fun shouldInterceptRequest(view: WebView?, request: WebResourceRequest): WebResourceResponse? {
+ val path = request.url.path ?: return null
+ if ("https".equals(request.url.scheme, true) && request.url.host == hostname) {
+ if (path == "/api/settings") return handleSettings(request)
+ if (path.startsWith("/locales/") && path.endsWith("/translation.json")) {
+ return handleTranslation(request)
+ }
+ if (vendorJsMatcher.matchEntire(path) != null) return handleVendorJs(request)
+ }
+ if (ReactMapHttpEngine.isCronet && (path.substringAfterLast('.').lowercase(Locale.ENGLISH)
+ in mediaExtensions || request.requestHeaders.any { (key, value) ->
+ "Accept".equals(key, true) && mediaAcceptMatcher.matches(value)
+ })) try {
+ val conn = ReactMapHttpEngine.engine.openConnection(URL(
+ request.url.toString())) as HttpURLConnection
+ setupConnection(request, conn)
+ return createResponse(conn) { conn.findErrorStream }
+ } catch (e: IOException) {
+ Timber.d(e)
+ }
+ return null
+ }
+
+ override fun onReceivedError(view: WebView?, request: WebResourceRequest, error: WebResourceError) {
+ if (!request.isForMainFrame) return
+ Snackbar.make(web, "${error.description} (${error.errorCode})", Snackbar.LENGTH_INDEFINITE).apply {
+ setAction(R.string.web_refresh) { web.reload() }
+ }.show()
+ }
+
+ override fun onRenderProcessGone(view: WebView, detail: RenderProcessGoneDetail): Boolean {
+ if (detail.didCrash()) {
+ Timber.w(Exception("WebView crashed @ priority ${detail.rendererPriorityAtExit()}"))
+ } else if (isAdded) {
+ FirebaseAnalytics.getInstance(context).logEvent("webviewExit",
+ bundleOf("priority" to detail.rendererPriorityAtExit()))
+ }
+ onRenderProcessGone()
+ return true
+ }
+ }
+ setRendererPriorityPolicy(WebView.RENDERER_PRIORITY_IMPORTANT, true)
+ setDownloadListener(this@BaseReactMapFragment)
+ }
+ return web
+ }
+
+ override fun onResume() {
+ super.onResume()
+ if (loaded) return
+ loaded = true
+ web.loadUrl(findActiveUrl())
+ }
+
+ private fun setupConnection(request: WebResourceRequest, conn: HttpURLConnection) {
+ conn.requestMethod = request.method
+ for ((key, value) in request.requestHeaders) conn.addRequestProperty(key, value)
+ }
+ private fun createResponse(conn: HttpURLConnection, data: (Charset) -> InputStream): WebResourceResponse {
+ val charset = if (conn.contentEncoding == null) Charsets.UTF_8 else {
+ Charset.forName(conn.contentEncoding)
+ }
+ return newWebResourceResponse.newInstance(false, conn.contentType?.substringBefore(';'), conn.contentEncoding,
+ conn.responseCode, conn.responseMessage, conn.headerFields.mapValues { (_, value) -> value.joinToString() },
+ data(charset))
+ }
+ private fun buildResponse(request: WebResourceRequest, transform: (Reader) -> String) = try {
+ val url = request.url.toString()
+ val conn = ReactMapHttpEngine.connectWithCookie(url) { conn -> setupConnection(request, conn) }
+ createResponse(conn) { charset -> if (conn.responseCode in 200..299) try {
+ transform(conn.inputStream.bufferedReader(charset)).byteInputStream(charset)
+ } catch (e: IOException) {
+ Timber.d(e)
+ conn.inputStream.bufferedReader(charset).readText().byteInputStream(charset)
+ } else conn.findErrorStream.bufferedReader(charset).readText().byteInputStream(charset) }
+ } catch (e: IOException) {
+ Timber.d(e)
+ null
+ } catch (e: IllegalArgumentException) {
+ Timber.d(e)
+ null
+ }
+ private fun handleSettings(request: WebResourceRequest) = buildResponse(request) { reader ->
+ val response = reader.readText()
+ try {
+ val config = JSONObject(response)
+ onConfigAvailable(config)
+ val mapConfig = config.getJSONObject("map")
+ if (mapConfig.optJSONArray("holidayEffects")?.length() != 0) {
+ mapConfig.put("holidayEffects", JSONArray())
+ config.toString()
+ } else response
+ } catch (e: JSONException) {
+ Timber.w(e)
+ response
+ }
+ }
+ private fun handleTranslation(request: WebResourceRequest) = buildResponse(request) { reader ->
+ val response = reader.readText()
+ try {
+ loginText = JSONObject(response).getString("login")
+ } catch (e: JSONException) {
+ Timber.w(e)
+ }
+ response
+ }
+ private inline fun buildString(matcher: Matcher, work: ((String) -> Unit) -> Unit) = (if (Build.VERSION.SDK_INT >=
+ 34) StringBuilder().also { s ->
+ work { matcher.appendReplacement(s, it) }
+ matcher.appendTail(s)
+ } else StringBuffer().also { s ->
+ work { matcher.appendReplacement(s, it) }
+ matcher.appendTail(s)
+ }).toString()
+ private fun handleVendorJs(request: WebResourceRequest) = buildResponse(request) { reader ->
+ val response = reader.readText()
+ val matcher = injectMapInitialize.matcher(response)
+ if (!matcher.find()) {
+ Timber.w(Exception("injectMapInitialize unmatched"))
+ return@buildResponse response
+ }
+ buildString(matcher) { replace ->
+ replace("$1(window._hijackedMap=this)")
+ matcher.usePattern(injectMapFlyTo)
+ if (!matcher.find()) {
+ Timber.w(Exception("injectMapFlyTo unmatched"))
+ return@buildResponse response
+ }
+ replace("$1window._hijackedLocateControl&&(window._hijackedLocateControl._userPanned=!0);")
+ matcher.usePattern(injectLocateControlActivate)
+ if (!matcher.find()) {
+ Timber.w(Exception("injectLocateControlActivate unmatched"))
+ return@buildResponse response
+ }
+ replace("$1window._hijackedLocateControl=")
+ }
+ }
+
+ override fun onDestroyView() {
+ super.onDestroyView()
+ web.destroy()
+ }
+
+ fun terminate() {
+ web.destroy()
+ try {
+ for (file in File("/proc/self/fd").listFiles() ?: emptyArray()) try {
+ val fdInt = file.name.toInt()
+ val fd = FileDescriptor().apply { setInt(this, fdInt) }
+ val endpoint = try {
+ Os.getsockname(fd)
+ } catch (e: ErrnoException) {
+ if (e.errno == OsConstants.EBADF || e.errno == OsConstants.ENOTSOCK) continue else throw e
+ }
+ if (endpoint !is InetSocketAddress) continue
+ val isTcp = when (val type = UnblockCentral.getsockoptInt(null, fd, OsConstants.SOL_SOCKET,
+ OsConstants.SO_TYPE)) {
+ OsConstants.SOCK_STREAM -> true
+ OsConstants.SOCK_DGRAM -> false
+ else -> {
+ Timber.w(Exception("Unknown $type to $endpoint"))
+ continue
+ }
+ }
+ val ownerTag = if (Build.VERSION.SDK_INT >= 29) try {
+ UnblockCentral.fdsanGetOwnerTag(os, fd) as Long
+ } catch (e: Exception) {
+ Timber.w(e)
+ 0
+ } else 0
+ Timber.d("Resetting $fdInt owned by $ownerTag if is 0 -> $endpoint $isTcp")
+ if (ownerTag != 0L) continue
+ if (isTcp) {
+ UnblockCentral.setsockoptLinger(null, fd, OsConstants.SOL_SOCKET,
+ OsConstants.SO_LINGER, UnblockCentral.lingerReset)
+ } else Os.dup2(nullFd, fdInt)
+ } catch (e: Exception) {
+ Timber.w(e)
+ }
+ } catch (e: IOException) {
+ Timber.d(e)
+ }
+ }
+}
diff --git a/app/src/main/java/be/mygod/reactmap/webkit/Glocation.kt b/app/src/main/java/be/mygod/reactmap/webkit/Glocation.kt
index 43bb481..17cc477 100644
--- a/app/src/main/java/be/mygod/reactmap/webkit/Glocation.kt
+++ b/app/src/main/java/be/mygod/reactmap/webkit/Glocation.kt
@@ -7,12 +7,11 @@ import android.location.Location
import android.os.Looper
import android.webkit.JavascriptInterface
import android.webkit.WebView
-import androidx.activity.result.contract.ActivityResultContracts
import androidx.annotation.RequiresPermission
-import androidx.fragment.app.Fragment
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleOwner
+import androidx.lifecycle.lifecycleScope
import be.mygod.reactmap.App.Companion.app
import be.mygod.reactmap.R
import be.mygod.reactmap.util.readableMessage
@@ -21,9 +20,10 @@ 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.Priority
+import kotlinx.coroutines.launch
import timber.log.Timber
-class Glocation(private val web: WebView, private val fragment: Fragment) : DefaultLifecycleObserver {
+class Glocation(private val web: WebView, private val fragment: BaseReactMapFragment) : DefaultLifecycleObserver {
companion object {
const val PERMISSION_DENIED = 1
const val POSITION_UNAVAILABLE = 2
@@ -85,18 +85,9 @@ class Glocation(private val web: WebView, private val fragment: Fragment) : Defa
fun setupGeolocation() = web.evaluateJavascript(jsSetup, null)
- private fun checkPermissions() = when (context.checkSelfPermission(Manifest.permission.ACCESS_FINE_LOCATION)) {
- PackageManager.PERMISSION_GRANTED -> true
- else -> {
- requestLocation.launch(arrayOf(
- Manifest.permission.ACCESS_FINE_LOCATION, Manifest.permission.ACCESS_COARSE_LOCATION))
- null
- }
- }
-
- private val requestLocation = fragment.registerForActivityResult(
- ActivityResultContracts.RequestMultiplePermissions()) { permissions ->
- val granted = permissions.any { (_, v) -> v }
+ private fun checkPermissions() = if (context.checkSelfPermission(Manifest.permission.ACCESS_FINE_LOCATION) ==
+ PackageManager.PERMISSION_GRANTED) true else fragment.requestLocationPermissions()
+ fun onPermissionResult(granted: Boolean) {
if (pendingRequests.isNotEmpty()) {
getCurrentPosition(granted, pendingRequests.joinToString())
pendingRequests.clear()
@@ -112,7 +103,7 @@ class Glocation(private val web: WebView, private val fragment: Fragment) : Defa
Timber.d("getCurrentPosition($i)")
when (val granted = checkPermissions()) {
null -> pendingRequests.add(i)
- else -> getCurrentPosition(granted, i.toString())
+ else -> fragment.lifecycleScope.launch { getCurrentPosition(granted, i.toString()) }
}
}
@@ -134,7 +125,7 @@ class Glocation(private val web: WebView, private val fragment: Fragment) : Defa
if (!activeListeners.add(i) || requestingLocationUpdates || pendingWatch) return
when (val granted = checkPermissions()) {
null -> pendingWatch = true
- else -> watchPosition(granted)
+ else -> fragment.lifecycleScope.launch { watchPosition(granted) }
}
}
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 af80a67..ea03ee8 100644
--- a/app/src/main/java/be/mygod/reactmap/webkit/ReactMapFragment.kt
+++ b/app/src/main/java/be/mygod/reactmap/webkit/ReactMapFragment.kt
@@ -1,121 +1,44 @@
package be.mygod.reactmap.webkit
-import android.annotation.SuppressLint
-import android.graphics.Bitmap
+import android.Manifest
import android.net.Uri
-import android.os.Build
import android.os.Bundle
-import android.util.JsonWriter
-import android.util.Log
-import android.view.LayoutInflater
import android.view.View
-import android.view.ViewGroup
-import android.webkit.ConsoleMessage
-import android.webkit.RenderProcessGoneDetail
import android.webkit.ValueCallback
-import android.webkit.WebChromeClient
-import android.webkit.WebResourceError
-import android.webkit.WebResourceRequest
-import android.webkit.WebResourceResponse
-import android.webkit.WebView
-import android.webkit.WebViewClient
+import android.webkit.WebChromeClient.FileChooserParams
import androidx.activity.OnBackPressedCallback
import androidx.activity.result.contract.ActivityResultContracts
-import androidx.core.net.toUri
-import androidx.core.os.bundleOf
import androidx.core.view.WindowCompat
-import androidx.fragment.app.Fragment
import androidx.fragment.app.setFragmentResult
import androidx.lifecycle.lifecycleScope
import be.mygod.reactmap.App.Companion.app
import be.mygod.reactmap.MainActivity
import be.mygod.reactmap.R
-import be.mygod.reactmap.follower.BackgroundLocationReceiver
import be.mygod.reactmap.util.CreateDynamicDocument
-import be.mygod.reactmap.util.findErrorStream
import com.google.android.material.snackbar.Snackbar
-import com.google.firebase.analytics.FirebaseAnalytics
import kotlinx.coroutines.launch
-import org.json.JSONArray
-import org.json.JSONException
import org.json.JSONObject
import org.json.JSONTokener
import timber.log.Timber
-import java.io.IOException
-import java.io.InputStream
-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
-import java.util.regex.Matcher
-class ReactMapFragment : Fragment() {
+class ReactMapFragment : BaseReactMapFragment() {
companion object {
private const val HOST_APPLE_MAPS = "maps.apple.com"
private const val DADDR_APPLE_MAPS = "daddr"
private val filenameExtractor = "filename=(\"([^\"]+)\"|[^;]+)".toRegex(RegexOption.IGNORE_CASE)
- // https://github.com/rollup/rollup/blob/10bdaa325a94ca632ef052e929a3e256dc1c7ade/docs/configuration-options/index.md?plain=1#L876
- private val vendorJsMatcher = "/vendor-[0-9a-z_-]{8}\\.js".toRegex(RegexOption.IGNORE_CASE)
private val flyToMatcher = "/@/([0-9.-]+)/([0-9.-]+)(?:/([0-9.-]+))?/?".toRegex()
-
- /**
- * Raw regex: ([,}][\n\r\s]*)this(?=\.callInitHooks\(\)[,;][\n\r\s]*this\._zoomAnimated\s*=)
- * if (options.center && options.zoom !== void 0) {
- * this.setView(toLatLng(options.center), options.zoom, { reset: true });
- * }
- * this.callInitHooks();
- * this._zoomAnimated = TRANSITION && any3d && !mobileOpera && this.options.zoomAnimation;
- * or match minimized fragment: ",this.callInitHooks(),this._zoomAnimated="
- */
- private val injectMapInitialize = "([,}][\\n\\r\\s]*)this(?=\\.callInitHooks\\(\\)[,;][\\n\\r\\s]*this\\._zoomAnimated\\s*=)"
- .toPattern()
- /**
- * Raw regex: ([;}][\n\r\s]*this\._stop\(\);)(?=[\n\r\s]*var )
- * if (options.animate === false || !any3d) {
- * return this.setView(targetCenter, targetZoom, options);
- * }
- * this._stop();
- * var from = this.project(this.getCenter()), to = this.project(targetCenter), size = this.getSize(), startZoom = this._zoom;
- * or match minimized fragment: ";this._stop();var "
- */
- private val injectMapFlyTo = "([;}][\\n\\r\\s]*this\\._stop\\(\\);)(?=[\\n\\r\\s]*var )".toPattern()
- /**
- * Raw regex: ([,;][\n\r\s]*this\._map\.on\("locationfound",\s*this\._onLocationFound,\s*)(?=this\)[,;])
- * this._active = true;
- * this._map.on("locationfound", this._onLocationFound, this);
- * this._map.on("locationerror", this._onLocationError, this);
- * or match minimized fragment: ",this._map.on("locationfound",this._onLocationFound,this),"
- */
- private val injectLocateControlActivate = "([,;][\\n\\r\\s]*this\\._map\\.on\\(\"locationfound\",\\s*this\\._onLocationFound,\\s*)(?=this\\)[,;])"
- .toPattern()
-
- private val supportedHosts = setOf("discordapp.com", "discord.com", "telegram.org", "oauth.telegram.org")
- private val mediaExtensions = setOf(
- "apng", "png", "avif", "gif", "jpg", "jpeg", "jfif", "pjpeg", "pjp", "png", "svg", "webp", "bmp", "ico", "cur",
- "wav", "mp3", "mp4", "aac", "ogg", "flac",
- "css", "js",
- "ttf", "otf", "woff", "woff2",
- )
- private val mediaAcceptMatcher = "image/.*|text/css(?:[,;].*)?".toRegex(RegexOption.IGNORE_CASE)
-
- private val newWebResourceResponse by lazy {
- WebResourceResponse::class.java.getDeclaredConstructor(Boolean::class.java, String::class.java,
- String::class.java, Int::class.java, String::class.java, Map::class.java, InputStream::class.java)
- }
}
- private lateinit var web: WebView
- private lateinit var glocation: Glocation
private lateinit var siteController: SiteController
- private lateinit var hostname: String
- private var loginText: String? = null
private val mainActivity by lazy { activity as MainActivity }
private val windowInsetsController by lazy { WindowCompat.getInsetsController(mainActivity.window, web) }
+ private val requestLocation = registerForActivityResult(
+ ActivityResultContracts.RequestMultiplePermissions()) { permissions ->
+ glocation.onPermissionResult(permissions.any { (_, v) -> v })
+ }
private var pendingFileCallback: ValueCallback>? = null
private val getContent = registerForActivityResult(ActivityResultContracts.GetContent()) { uri ->
pendingFileCallback?.onReceiveValue(if (uri == null) emptyArray() else arrayOf(uri))
@@ -129,269 +52,81 @@ class ReactMapFragment : Fragment() {
}
pendingJson = null
}
- private var loaded = false
- override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
- Timber.d("Creating ReactMapFragment")
- web = WebView(mainActivity).apply {
- settings.apply {
- domStorageEnabled = true
- @SuppressLint("SetJavaScriptEnabled")
- javaScriptEnabled = true
- }
- glocation = Glocation(this, this@ReactMapFragment)
- siteController = SiteController(this@ReactMapFragment)
- webChromeClient = object : WebChromeClient() {
- @Suppress("KotlinConstantConditions")
- override fun onConsoleMessage(consoleMessage: ConsoleMessage) = consoleMessage.run {
- Timber.tag("WebConsole").log(when (messageLevel()) {
- ConsoleMessage.MessageLevel.TIP -> Log.INFO
- ConsoleMessage.MessageLevel.LOG -> Log.VERBOSE
- ConsoleMessage.MessageLevel.WARNING -> Log.WARN
- ConsoleMessage.MessageLevel.ERROR -> Log.ERROR
- ConsoleMessage.MessageLevel.DEBUG -> Log.DEBUG
- else -> error(messageLevel())
- }, "${sourceId()}:${lineNumber()} - ${message()}")
- true
- }
-
- override fun onReceivedTitle(view: WebView?, title: String?) {
- siteController.title = title
- }
-
- override fun onShowFileChooser(webView: WebView, filePathCallback: ValueCallback>?,
- fileChooserParams: FileChooserParams): Boolean {
- require(fileChooserParams.mode == FileChooserParams.MODE_OPEN)
- pendingFileCallback?.onReceiveValue(null)
- pendingFileCallback = filePathCallback
- getContent.launch(fileChooserParams.acceptTypes.single())
- return true
- }
- }
- val onBackPressedCallback = object : OnBackPressedCallback(false) {
- override fun handleOnBackPressed() = web.goBack()
- }
- mainActivity.onBackPressedDispatcher.addCallback(viewLifecycleOwner, onBackPressedCallback)
- val muiMargin = ReactMapMuiMarginListener(this)
- webViewClient = object : WebViewClient() {
- override fun doUpdateVisitedHistory(view: WebView?, url: String, isReload: Boolean) {
- onBackPressedCallback.isEnabled = web.canGoBack()
- if (url.toUri().path?.trimEnd('/') == "/login") loginText?.let { login ->
- val writer = StringWriter()
- writer.write("document.location = document.evaluate('//a[text()=")
- JsonWriter(writer).use {
- it.isLenient = true
- it.value(login)
- }
- writer.write(
- "]', document, null, XPathResult.FIRST_ORDERED_NODE_TYPE, null).singleNodeValue.href;")
- web.evaluateJavascript(writer.toString(), null)
- }
- }
-
- override fun onPageStarted(view: WebView, url: String, favicon: Bitmap?) {
- glocation.clear()
- if (url.toUri().host == hostname) glocation.setupGeolocation()
- }
-
- override fun onPageFinished(view: WebView?, url: String) {
- mainActivity.pendingOverrideUri = null
- 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 {
- val parsed = request.url
- return when {
- parsed.host?.lowercase(Locale.ENGLISH).let { it != hostname && it !in supportedHosts } -> {
- app.launchUrl(view.context, parsed)
- true
- }
- "http".equals(parsed.scheme, true) -> {
- Snackbar.make(view, R.string.error_https_only, Snackbar.LENGTH_SHORT).show()
- true
- }
- else -> false
- }
- }
-
- override fun shouldInterceptRequest(view: WebView?, request: WebResourceRequest): WebResourceResponse? {
- val path = request.url.path ?: return null
- if ("https".equals(request.url.scheme, true) && request.url.host == hostname) {
- if (path == "/api/settings") return handleSettings(request)
- if (path.startsWith("/locales/") && path.endsWith("/translation.json")) {
- return handleTranslation(request)
- }
- if (vendorJsMatcher.matchEntire(path) != null) return handleVendorJs(request)
- }
- if (ReactMapHttpEngine.isCronet && (path.substringAfterLast('.')
- .lowercase(Locale.ENGLISH) in mediaExtensions || request.requestHeaders.any { (key, value) ->
- "Accept".equals(key, true) && mediaAcceptMatcher.matches(value)
- })) try {
- val conn = ReactMapHttpEngine.engine.openConnection(URL(
- request.url.toString())) as HttpURLConnection
- setupConnection(request, conn)
- return createResponse(conn) { conn.findErrorStream }
- } catch (e: IOException) {
- Timber.d(e)
- }
- return null
- }
-
- override fun onReceivedError(view: WebView?, request: WebResourceRequest, error: WebResourceError) {
- if (!request.isForMainFrame) return
- Snackbar.make(web, "${error.description} (${error.errorCode})", Snackbar.LENGTH_INDEFINITE).apply {
- setAction(R.string.web_refresh) { web.reload() }
- }.show()
- }
-
- override fun onRenderProcessGone(view: WebView, detail: RenderProcessGoneDetail): Boolean {
- mainActivity.currentFragment = null
- if (detail.didCrash()) {
- Timber.w(Exception("WebView crashed @ priority ${detail.rendererPriorityAtExit()}"))
- } else if (isAdded) {
- FirebaseAnalytics.getInstance(context).logEvent("webviewExit",
- bundleOf("priority" to detail.rendererPriorityAtExit()))
- }
- // WebView cannot be reused but keep the process alive if possible. pretend to ask to recreate
- if (isAdded) setFragmentResult("ReactMapFragment", Bundle())
- return true
- }
- }
- setRendererPriorityPolicy(WebView.RENDERER_PRIORITY_IMPORTANT, true)
- setDownloadListener { url, _, contentDisposition, mimetype, _ ->
- if (!url.startsWith("data:", true)) {
- Snackbar.make(web, context.getString(R.string.error_unsupported_download, url),
- Snackbar.LENGTH_LONG).show()
- return@setDownloadListener
- }
- pendingJson = URLDecoder.decode(url.substringAfter(','), "utf-8")
- createDocument.launch(mimetype to (filenameExtractor.find(contentDisposition)?.run {
- groupValues[2].ifEmpty { groupValues[1] }
- } ?: "settings.json"))
- }
+ override fun onShowFileChooser(filePathCallback: ValueCallback>?, fileChooserParams: FileChooserParams) {
+ require(fileChooserParams.mode == FileChooserParams.MODE_OPEN)
+ pendingFileCallback?.onReceiveValue(null)
+ pendingFileCallback = filePathCallback
+ getContent.launch(fileChooserParams.acceptTypes.single())
+ }
+ override fun onDownloadStart(url: String, userAgent: String?, contentDisposition: String, mimetype: String,
+ contentLength: Long) {
+ if (!url.startsWith("data:", true)) {
+ Snackbar.make(web, requireContext().getString(R.string.error_unsupported_download, url),
+ Snackbar.LENGTH_LONG).show()
+ return
}
- return web
+ pendingJson = URLDecoder.decode(url.substringAfter(','), "utf-8")
+ createDocument.launch(mimetype to (filenameExtractor.find(contentDisposition)?.run {
+ groupValues[2].ifEmpty { groupValues[1] }
+ } ?: "settings.json"))
}
-
- override fun onResume() {
- super.onResume()
- if (loaded) return
- loaded = true
- val overrideUrl = mainActivity.pendingOverrideUri
- val activeUrl = (activeUrl@{
- if (HOST_APPLE_MAPS.equals(overrideUrl?.host, true)) {
- val daddr = overrideUrl?.getQueryParameter(DADDR_APPLE_MAPS)
- if (!daddr.isNullOrBlank()) {
- hostname = Uri.parse(app.activeUrl).host!!
- return@activeUrl "https://$hostname/@/${daddr.replace(',', '/')}"
- }
- }
- if (overrideUrl != null) {
- hostname = overrideUrl.host!!
- overrideUrl.toString()
- } else app.activeUrl.also { hostname = Uri.parse(it).host!! }
- })()
- web.loadUrl(activeUrl)
+ override fun onReceiveTitle(title: String?) {
+ siteController.title = title
}
-
- private fun setupConnection(request: WebResourceRequest, conn: HttpURLConnection) {
- conn.requestMethod = request.method
- for ((key, value) in request.requestHeaders) conn.addRequestProperty(key, value)
+ override fun requestLocationPermissions() = null.also {
+ requestLocation.launch(arrayOf(Manifest.permission.ACCESS_FINE_LOCATION,
+ Manifest.permission.ACCESS_COARSE_LOCATION))
}
- private fun createResponse(conn: HttpURLConnection, data: (Charset) -> InputStream): WebResourceResponse {
- val charset = if (conn.contentEncoding == null) Charsets.UTF_8 else {
- Charset.forName(conn.contentEncoding)
- }
- return newWebResourceResponse.newInstance(false, conn.contentType?.substringBefore(';'), conn.contentEncoding,
- conn.responseCode, conn.responseMessage, conn.headerFields.mapValues { (_, value) -> value.joinToString() },
- data(charset))
+
+ private lateinit var onBackPressedCallback: OnBackPressedCallback
+ override fun onHistoryUpdated() {
+ onBackPressedCallback.isEnabled = web.canGoBack()
}
- private fun buildResponse(request: WebResourceRequest, transform: (Reader) -> String) = try {
- val url = request.url.toString()
- val conn = ReactMapHttpEngine.connectWithCookie(url) { conn -> setupConnection(request, conn) }
- createResponse(conn) { charset -> if (conn.responseCode in 200..299) try {
- transform(conn.inputStream.bufferedReader(charset)).byteInputStream(charset)
- } catch (e: IOException) {
- Timber.d(e)
- conn.inputStream.bufferedReader(charset).readText().byteInputStream(charset)
- } else conn.findErrorStream.bufferedReader(charset).readText().byteInputStream(charset) }
- } catch (e: IOException) {
- Timber.d(e)
- null
- } catch (e: IllegalArgumentException) {
- Timber.d(e)
- null
+ override fun onPageFinished() {
+ mainActivity.pendingOverrideUri = null
}
- private fun handleSettings(request: WebResourceRequest) = buildResponse(request) { reader ->
- val response = reader.readText()
- try {
- val config = JSONObject(response)
- val tileServers = config.getJSONArray("tileServers")
- lifecycleScope.launch {
- web.evaluateJavascript("JSON.parse(localStorage.getItem('local-state')).state.settings.tileServers") {
- val name = JSONTokener(it).nextValue() as? String
- windowInsetsController.isAppearanceLightStatusBars =
- (tileServers.length() - 1 downTo 0).asSequence().map { i -> tileServers.getJSONObject(i) }
- .firstOrNull { obj -> obj.getString("name") == name }?.optString("style") != "dark"
- }
- }
- val mapConfig = config.getJSONObject("map")
- if (mapConfig.optJSONArray("holidayEffects")?.length() != 0) {
- mapConfig.put("holidayEffects", JSONArray())
- config.toString()
- } else response
- } catch (e: JSONException) {
- Timber.w(e)
- response
+
+ override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
+ super.onViewCreated(view, savedInstanceState)
+ siteController = SiteController(this)
+ onBackPressedCallback = object : OnBackPressedCallback(false) {
+ override fun handleOnBackPressed() = web.goBack()
}
+ mainActivity.onBackPressedDispatcher.addCallback(viewLifecycleOwner, onBackPressedCallback)
}
- private fun handleTranslation(request: WebResourceRequest) = buildResponse(request) { reader ->
- val response = reader.readText()
- try {
- loginText = JSONObject(response).getString("login")
- } catch (e: JSONException) {
- Timber.w(e)
+
+ override fun findActiveUrl(): String {
+ val overrideUrl = mainActivity.pendingOverrideUri
+ if (HOST_APPLE_MAPS.equals(overrideUrl?.host, true)) {
+ val daddr = overrideUrl?.getQueryParameter(DADDR_APPLE_MAPS)
+ if (!daddr.isNullOrBlank()) {
+ hostname = Uri.parse(app.activeUrl).host!!
+ return "https://$hostname/@/${daddr.replace(',', '/')}"
+ }
}
- response
+ return if (overrideUrl != null) {
+ hostname = overrideUrl.host!!
+ overrideUrl.toString()
+ } else app.activeUrl.also { hostname = Uri.parse(it).host!! }
}
- private inline fun buildString(matcher: Matcher, work: ((String) -> Unit) -> Unit) = (if (Build.VERSION.SDK_INT >=
- 34) StringBuilder().also { s ->
- work { matcher.appendReplacement(s, it) }
- matcher.appendTail(s)
- } else StringBuffer().also { s ->
- work { matcher.appendReplacement(s, it) }
- matcher.appendTail(s)
- }).toString()
- private fun handleVendorJs(request: WebResourceRequest) = buildResponse(request) { reader ->
- val response = reader.readText()
- val matcher = injectMapInitialize.matcher(response)
- if (!matcher.find()) {
- Timber.w(Exception("injectMapInitialize unmatched"))
- return@buildResponse response
- }
- buildString(matcher) { replace ->
- replace("$1(window._hijackedMap=this)")
- matcher.usePattern(injectMapFlyTo)
- if (!matcher.find()) {
- Timber.w(Exception("injectMapFlyTo unmatched"))
- return@buildResponse response
- }
- replace("$1window._hijackedLocateControl&&(window._hijackedLocateControl._userPanned=!0);")
- matcher.usePattern(injectLocateControlActivate)
- if (!matcher.find()) {
- Timber.w(Exception("injectLocateControlActivate unmatched"))
- return@buildResponse response
+
+ override fun onConfigAvailable(config: JSONObject) {
+ val tileServers = config.getJSONArray("tileServers")
+ lifecycleScope.launch {
+ web.evaluateJavascript("JSON.parse(localStorage.getItem('local-state')).state.settings.tileServers") {
+ val name = JSONTokener(it).nextValue() as? String
+ windowInsetsController.isAppearanceLightStatusBars =
+ (tileServers.length() - 1 downTo 0).asSequence().map { i -> tileServers.getJSONObject(i) }
+ .firstOrNull { obj -> obj.getString("name") == name }?.optString("style") != "dark"
}
- replace("$1window._hijackedLocateControl=")
}
}
- override fun onDestroyView() {
- super.onDestroyView()
- web.destroy()
+ override fun onRenderProcessGone() {
+ mainActivity.currentFragment = null
+ // WebView cannot be reused but keep the process alive if possible. pretend to ask to recreate
+ if (isAdded) setFragmentResult("ReactMapFragment", Bundle())
}
private fun flyToUrl(destination: String, zoom: String? = null, urlOnFail: () -> String) {
@@ -428,5 +163,4 @@ class ReactMapFragment : Fragment() {
flyToUrl("${match.groupValues[1]}, ${match.groupValues[2]}", match.groups[3]?.value) { uri.toString() }
false
}
- fun terminate() = web.destroy()
}
diff --git a/app/src/main/res/drawable/ic_maps_directions_car.xml b/app/src/main/res/drawable/ic_maps_directions_car.xml
new file mode 100644
index 0000000..2f1290c
--- /dev/null
+++ b/app/src/main/res/drawable/ic_maps_directions_car.xml
@@ -0,0 +1,5 @@
+
+
+
+
+
diff --git a/app/src/main/res/layout/layout_car.xml b/app/src/main/res/layout/layout_car.xml
new file mode 100644
index 0000000..c7924ba
--- /dev/null
+++ b/app/src/main/res/layout/layout_car.xml
@@ -0,0 +1,36 @@
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/raw/setup_keyboard.js b/app/src/main/res/raw/setup_keyboard.js
new file mode 100644
index 0000000..8217643
--- /dev/null
+++ b/app/src/main/res/raw/setup_keyboard.js
@@ -0,0 +1,21 @@
+window._autoKeyboardCallback = {
+ _textTypes: new Set(['text', 'password', 'number', 'email', 'tel', 'url', 'search', 'date', 'datetime', 'datetime-local', 'time', 'month', 'week']),
+ _setValue: Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value').set,
+ _handler: function (e) {
+ if (this._ignoreNextFocus && e instanceof FocusEvent) return delete this._ignoreNextFocus;
+ if (!this._currentInput && (e.target instanceof HTMLTextAreaElement || e.target instanceof HTMLInputElement &&
+ this._textTypes.has((e.target.getAttribute('type') || '').toLowerCase())) &&
+ window._autoKeyboard.request(e.target.value, e.target.placeholder)) this._currentInput = e.target;
+ },
+ valueReady: function (value) {
+ this._ignoreNextFocus = true;
+ this._setValue.call(this._currentInput, value);
+ this._currentInput.dispatchEvent(new Event('input', { bubbles: true }));
+ delete this._currentInput;
+ },
+ dismiss: function () {
+ delete this._currentInput;
+ },
+};
+document.addEventListener('click', window._autoKeyboardCallback._handler.bind(window._autoKeyboardCallback));
+document.addEventListener('focusin', window._autoKeyboardCallback._handler.bind(window._autoKeyboardCallback));
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 548f3e4..2f14582 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -35,4 +35,28 @@
Enter split screen
Refresh
+
+ Terms of Use\nBy using %s (“the Map”) on your vehicle, you
+ acknowledge and agree to the following terms:\n- Development Purposes Only:
+ You understand that the Map is provided for development and testing purposes only.
+ It may contain errors or inaccuracies and should not be relied upon for navigation or critical
+ decision-making.
\n- Assumption of Responsibility:
+ You assume all responsibility and risk for any consequences resulting from your use of the Map.
+ This includes, but is not limited to, any accidents, injuries, or legal issues that may arise due to
+ distraction or misuse.
\n- Restricted Use While Driving:
+ As a driver, you agree to use the Map only when the vehicle is stationary and if it is safe to do so.
+ Drivers must not use the Map while the vehicle is in motion.
+ Passengers may use the Map while the vehicle is moving, provided it does not distract the driver or
+ interfere with the safe operation of the vehicle.
\n- Compliance with Laws:
+ You agree to adhere to all applicable traffic laws and regulations related to the use of electronic devices
+ and navigation aids while operating a vehicle.
\n- No Liability:
+ The developers and distributors of the Map shall not be held liable for any direct or indirect damages
+ arising from its use.
\nBy proceeding to use the Map, you confirm that you have read, understood,
+ and agree to abide by these terms and conditions.
+ Proceed
+ Please perform this action on your device instead
+ Attempting to open the target webpage on your device. This may require
+ opening the app or the permission to display over other apps
+ Android Auto map controls
+ Tap to end session
diff --git a/app/src/main/res/xml/automotive_app_desc.xml b/app/src/main/res/xml/automotive_app_desc.xml
new file mode 100644
index 0000000..f474148
--- /dev/null
+++ b/app/src/main/res/xml/automotive_app_desc.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
diff --git a/gradle.properties b/gradle.properties
index ace1bff..8ed44cc 100644
--- a/gradle.properties
+++ b/gradle.properties
@@ -21,6 +21,7 @@ kotlin.code.style=official
# resources declared in the library itself and none from the library's dependencies,
# thereby reducing the size of the R class for that library
android.nonTransitiveRClass=true
+android.enableJetifier=true
android.enableResourceOptimizations=false
# ReactMap settings for customized build