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]+\\$| "%" + "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