Files
vpnhotspotmod/mobile/src/main/java/be/mygod/vpnhotspot/util/Utils.kt
2023-03-10 00:18:56 -05:00

291 lines
12 KiB
Kotlin

package be.mygod.vpnhotspot.util
import android.annotation.SuppressLint
import android.annotation.TargetApi
import android.content.*
import android.content.res.Resources
import android.net.*
import android.net.http.ConnectionMigrationOptions
import android.net.http.HttpEngine
import android.os.Build
import android.os.RemoteException
import android.text.*
import android.view.MenuItem
import android.view.View
import android.widget.ImageView
import androidx.annotation.DrawableRes
import androidx.annotation.RequiresApi
import androidx.core.net.toUri
import androidx.core.os.BuildCompat
import androidx.core.view.isVisible
import androidx.databinding.BindingAdapter
import androidx.fragment.app.DialogFragment
import androidx.fragment.app.FragmentManager
import be.mygod.vpnhotspot.App.Companion.app
import be.mygod.vpnhotspot.client.MacLookup
import be.mygod.vpnhotspot.net.MacAddressCompat
import be.mygod.vpnhotspot.widget.SmartSnackbar
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.suspendCancellableCoroutine
import timber.log.Timber
import java.io.File
import java.lang.invoke.MethodHandles
import java.lang.reflect.InvocationHandler
import java.lang.reflect.InvocationTargetException
import java.lang.reflect.Method
import java.net.HttpURLConnection
import java.net.InetAddress
import java.net.NetworkInterface
import java.net.SocketException
import java.net.URL
import java.util.Locale
import kotlin.coroutines.resume
import kotlin.coroutines.resumeWithException
tailrec fun Throwable.getRootCause(): Throwable {
if (this is InvocationTargetException || this is RemoteException) return (cause ?: return this).getRootCause()
return this
}
val Throwable.readableMessage: String get() = getRootCause().run { localizedMessage ?: javaClass.name }
/**
* This is a hack: we wrap longs around in 1 billion and such. Hopefully every language counts in base 10 and this works
* marvelously for everybody.
*/
fun Long.toPluralInt(): Int {
check(this >= 0) // please don't mess with me
if (this <= Int.MAX_VALUE) return toInt()
return (this % 1000000000).toInt() + 1000000000
}
fun Method.matches(name: String, vararg classes: Class<*>) = this.name == name && parameterCount == classes.size &&
classes.indices.all { i -> parameters[i].type == classes[i] }
inline fun <reified T> Method.matches1(name: String) = matches(name, T::class.java)
fun Context.ensureReceiverUnregistered(receiver: BroadcastReceiver) {
try {
unregisterReceiver(receiver)
} catch (_: IllegalArgumentException) { }
}
fun DialogFragment.showAllowingStateLoss(manager: FragmentManager, tag: String? = null) {
if (!manager.isStateSaved) show(manager, tag)
}
fun broadcastReceiver(receiver: (Context, Intent) -> Unit) = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) = receiver(context, intent)
}
fun intentFilter(vararg actions: String) = IntentFilter().also { actions.forEach(it::addAction) }
@BindingAdapter("android:src")
fun setImageResource(imageView: ImageView, @DrawableRes resource: Int) = imageView.setImageResource(resource)
@BindingAdapter("android:visibility")
fun setVisibility(view: View, value: Boolean) {
view.isVisible = value
}
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
}
}
fun <T> Iterable<T>.joinToSpanned(separator: CharSequence = ", ", prefix: CharSequence = "", postfix: CharSequence = "",
limit: Int = -1, truncated: CharSequence = "...",
transform: ((T) -> CharSequence)? = null) =
joinTo(SpannableStringBuilder(), separator, prefix, postfix, limit, truncated, transform)
fun <T> Sequence<T>.joinToSpanned(separator: CharSequence = ", ", prefix: CharSequence = "", postfix: CharSequence = "",
limit: Int = -1, truncated: CharSequence = "...",
transform: ((T) -> CharSequence)? = null) =
joinTo(SpannableStringBuilder(), separator, prefix, postfix, limit, truncated, transform)
fun makeIpSpan(ip: InetAddress) = ip.hostAddress.let {
// exclude all bogon IP addresses supported by Android APIs
if (!app.hasTouch || ip.isMulticastAddress || ip.isAnyLocalAddress || ip.isLoopbackAddress ||
ip.isLinkLocalAddress || ip.isSiteLocalAddress || ip.isMCGlobal || ip.isMCNodeLocal ||
ip.isMCLinkLocal || ip.isMCSiteLocal || ip.isMCOrgLocal) it else SpannableString(it).apply {
setSpan(CustomTabsUrlSpan("https://ipinfo.io/$it"), 0, length, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
}
}
fun makeMacSpan(mac: String) = if (app.hasTouch) SpannableString(mac).apply {
setSpan(CustomTabsUrlSpan("https://maclookup.app/search/result?mac=$mac"), 0, length,
Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
} else mac
fun NetworkInterface.formatAddresses(macOnly: Boolean = false) = SpannableStringBuilder().apply {
try {
val address = hardwareAddress?.let(MacAddress::fromBytes)
if (address != null && address != MacAddressCompat.ANY_ADDRESS) appendLine(makeMacSpan(address.toString()))
} catch (e: IllegalArgumentException) {
Timber.w(e)
} catch (_: SocketException) { }
if (!macOnly) for (address in interfaceAddresses) {
append(makeIpSpan(address.address))
appendLine("/${address.networkPrefixLength}")
}
}.trimEnd()
private val parseNumericAddress by lazy @SuppressLint("SoonBlockedPrivateApi") {
InetAddress::class.java.getDeclaredMethod("parseNumericAddress", String::class.java).apply {
isAccessible = true
}
}
fun parseNumericAddress(address: String) = if (Build.VERSION.SDK_INT >= 29) {
InetAddresses.parseNumericAddress(address)
} else parseNumericAddress(null, address) as InetAddress
private val getAllInterfaceNames by lazy { LinkProperties::class.java.getDeclaredMethod("getAllInterfaceNames") }
@Suppress("UNCHECKED_CAST")
val LinkProperties.allInterfaceNames get() = getAllInterfaceNames.invoke(this) as List<String>
private val getAllRoutes by lazy { LinkProperties::class.java.getDeclaredMethod("getAllRoutes") }
@Suppress("UNCHECKED_CAST")
val LinkProperties.allRoutes get() = getAllRoutes.invoke(this) as List<RouteInfo>
fun Context.launchUrl(url: String) {
if (app.hasTouch) try {
app.customTabsIntent.launchUrl(this, url.toUri())
return
} catch (_: RuntimeException) { }
SmartSnackbar.make(url).show()
}
fun Context.stopAndUnbind(connection: ServiceConnection) {
connection.onServiceDisconnected(null)
unbindService(connection)
}
var MenuItem.isNotGone: Boolean
get() = isVisible || isEnabled
set(value) {
isVisible = value
isEnabled = value
}
fun Resources.findIdentifier(name: String, defType: String, defPackage: String, alternativePackage: String? = null) =
getIdentifier(name, defType, defPackage).let {
if (alternativePackage != null && it == 0) getIdentifier(name, defType, alternativePackage) else it
}
private val newLookup by lazy {
MethodHandles.Lookup::class.java.getDeclaredConstructor(Class::class.java, Int::class.java).apply {
isAccessible = true
}
}
/**
* Call interface super method.
*
* See also: https://stackoverflow.com/a/49532463/2245107
*/
fun InvocationHandler.callSuper(interfaceClass: Class<*>, proxy: Any, method: Method, args: Array<out Any?>?) = when {
method.isDefault -> try {
newLookup.newInstance(interfaceClass, 0xf) // ALL_MODES
} catch (e: ReflectiveOperationException) {
Timber.w(e)
MethodHandles.lookup().`in`(interfaceClass)
}.unreflectSpecial(method, interfaceClass).bindTo(proxy).run {
if (args == null) invokeWithArguments() else invokeWithArguments(*args)
}
// otherwise, we just redispatch it to InvocationHandler
method.declaringClass.isAssignableFrom(javaClass) -> when {
method.declaringClass == Object::class.java -> when (method.name) {
"hashCode" -> System.identityHashCode(proxy)
"equals" -> proxy === args!![0]
"toString" -> "${proxy.javaClass.name}@${System.identityHashCode(proxy).toString(16)}"
else -> error("Unsupported Object method dispatched")
}
args == null -> method(this)
else -> method(this, *args)
}
else -> {
Timber.w("Unhandled method: $method(${args?.contentDeepToString()})")
null
}
}
fun globalNetworkRequestBuilder() = NetworkRequest.Builder().apply {
if (Build.VERSION.SDK_INT >= 31) setIncludeOtherUidNetworks(true)
}
@get:RequiresApi(34)
private val engine by lazy @TargetApi(34) {
val cache = File(app.deviceStorage.cacheDir, "httpEngine")
HttpEngine.Builder(app.deviceStorage).apply {
if (cache.mkdirs() || cache.isDirectory) {
setStoragePath(cache.absolutePath)
setEnableHttpCache(HttpEngine.Builder.HTTP_CACHE_DISK, 1024 * 1024)
}
setConnectionMigrationOptions(ConnectionMigrationOptions.Builder().apply {
setEnableDefaultNetworkMigration(true)
setEnablePathDegradationMigration(true)
}.build())
setEnableBrotli(true)
addQuicHint(MacLookup.HOST, 443, 443)
}.build()
}
suspend fun <T> connectCancellable(url: String, block: suspend (HttpURLConnection) -> T): T {
val conn = (if (BuildCompat.isAtLeastU()) {
engine.openConnection(URL(url))
} else @Suppress("BlockingMethodInNonBlockingContext") URL(url).openConnection()) as HttpURLConnection
return suspendCancellableCoroutine { cont ->
val job = GlobalScope.launch(Dispatchers.IO) {
try {
cont.resume(block(conn))
} catch (e: Throwable) {
cont.resumeWithException(e)
} finally {
conn.disconnect()
}
}
cont.invokeOnCancellation {
job.cancel(it as? CancellationException)
conn.disconnect()
}
}
}