diff --git a/README.md b/README.md index a715049c..75601883 100644 --- a/README.md +++ b/README.md @@ -65,8 +65,20 @@ Default settings are picked to suit general use cases and maximize compatibility * Keep Wi-Fi alive: Acquire Wi-Fi locks when repeater, temporary hotspot or system VPN hotspot is activated. - Choose "System default" to save battery life; - - Choose "On" (default) if repeater/hotspot turns itself off automatically or stops working after a while; - - Choose "High Performance Mode" to minimize packet loss and latency (will consume more power). + - (prior to Android 10) Choose "On" (default) if repeater/hotspot turns itself off automatically or stops working after a while; + - (prior to Android 10) Choose "High Performance Mode" to minimize packet loss and latency (will consume more power); + - (since Android 10) Choose "Disable power save" to decrease packet latency. + An example use case is when a voice connection needs to be kept active even after the device screen goes off. + Using this mode may improve the call quality. + Requires support from the hardware. + - (since Android 10) Choose "Low latency mode" to optimize for reduced packet latency, and this might result in: + 1. Reduced battery life. + 2. Reduced throughput. + 3. Reduced frequency of Wi-Fi scanning. + This may cause the device not roaming or switching to the AP with highest signal quality, and location accuracy may be reduced. + Example use cases are real time gaming or virtual reality applications where low latency is a key factor for user experience. + Requires support from the hardware. + Note: Requires this app running in foreground with screen on. * Start repeater on boot: Self explanatory. * Network status monitor mode: This option controls how the app monitors connected devices as well as interface changes (when custom upstream is used). @@ -107,23 +119,27 @@ _a.k.a. things that can go wrong if this app doesn't work._ This is a list of stuff that might impact this app's functionality if unavailable. This is only meant to be an index. You can read more in the source code. -Undocumented API list: +Non-public API list: -* (since API 24) [`Landroid/bluetooth/BluetoothPan;->isTetheringOn()Z,greylist`](https://android.googlesource.com/platform/prebuilts/runtime/+/aa21a6e/appcompat/hiddenapi-flags.csv#28703) -* (since API 24) [`Landroid/net/ConnectivityManager$OnStartTetheringCallback;->()V,whitelist`](https://android.googlesource.com/platform/prebuilts/runtime/+/aa21a6e/appcompat/hiddenapi-flags.csv#112695) -* (since API 24) [`Landroid/net/ConnectivityManager$OnStartTetheringCallback;->onTetheringFailed()V,whitelist`](https://android.googlesource.com/platform/prebuilts/runtime/+/aa21a6e/appcompat/hiddenapi-flags.csv#112696) -* (since API 24) [`Landroid/net/ConnectivityManager$OnStartTetheringCallback;->onTetheringStarted()V,whitelist`](https://android.googlesource.com/platform/prebuilts/runtime/+/aa21a6e/appcompat/hiddenapi-flags.csv#112697) -* (since API 24) [`Landroid/net/ConnectivityManager;->getLastTetherError(Ljava/lang/String;)I,greylist`](https://android.googlesource.com/platform/prebuilts/runtime/+/aa21a6e/appcompat/hiddenapi-flags.csv#112882) -* (since API 24) [`Landroid/net/ConnectivityManager;->startTethering(IZLandroid/net/ConnectivityManager$OnStartTetheringCallback;Landroid/os/Handler;)V,whitelist`](https://android.googlesource.com/platform/prebuilts/runtime/+/aa21a6e/appcompat/hiddenapi-flags.csv#112972) -* (since API 24) [`Landroid/net/ConnectivityManager;->stopTethering(I)V,whitelist`](https://android.googlesource.com/platform/prebuilts/runtime/+/aa21a6e/appcompat/hiddenapi-flags.csv#112974) -* [`Landroid/net/wifi/p2p/WifiP2pGroup;->getNetworkId()I,greylist`](https://android.googlesource.com/platform/prebuilts/runtime/+/aa21a6e/appcompat/hiddenapi-flags.csv#123194) -* [`Landroid/net/wifi/p2p/WifiP2pGroupList;->getGroupList()Ljava/util/Collection;,greylist`](https://android.googlesource.com/platform/prebuilts/runtime/+/aa21a6e/appcompat/hiddenapi-flags.csv#123239) -* [`Landroid/net/wifi/p2p/WifiP2pManager;->deletePersistentGroup(Landroid/net/wifi/p2p/WifiP2pManager$Channel;ILandroid/net/wifi/p2p/WifiP2pManager$ActionListener;)V,greylist`](https://android.googlesource.com/platform/prebuilts/runtime/+/aa21a6e/appcompat/hiddenapi-flags.csv#123431) -* [`Landroid/net/wifi/p2p/WifiP2pManager;->requestPersistentGroupInfo(Landroid/net/wifi/p2p/WifiP2pManager$Channel;Landroid/net/wifi/p2p/WifiP2pManager$PersistentGroupInfoListener;)V,greylist`](https://android.googlesource.com/platform/prebuilts/runtime/+/aa21a6e/appcompat/hiddenapi-flags.csv#123450) -* [`Landroid/net/wifi/p2p/WifiP2pManager;->setWifiP2pChannels(Landroid/net/wifi/p2p/WifiP2pManager$Channel;IILandroid/net/wifi/p2p/WifiP2pManager$ActionListener;)V,greylist`](https://android.googlesource.com/platform/prebuilts/runtime/+/aa21a6e/appcompat/hiddenapi-flags.csv#123458) -* [`Landroid/net/wifi/p2p/WifiP2pManager;->startWps(Landroid/net/wifi/p2p/WifiP2pManager$Channel;Landroid/net/wifi/WpsInfo;Landroid/net/wifi/p2p/WifiP2pManager$ActionListener;)V,greylist`](https://android.googlesource.com/platform/prebuilts/runtime/+/aa21a6e/appcompat/hiddenapi-flags.csv#123459) -* [`Ljava/net/InetAddress;->parseNumericAddress(Ljava/lang/String;)Ljava/net/InetAddress;,greylist`](https://android.googlesource.com/platform/prebuilts/runtime/+/aa21a6e/appcompat/hiddenapi-flags.csv#299587) +* (since API 24) [`Landroid/bluetooth/BluetoothPan;->isTetheringOn()Z,greylist`](https://android.googlesource.com/platform/prebuilts/runtime/+/3d07e5c/appcompat/hiddenapi-flags.csv#32103) +* (since API 24) [`Landroid/net/ConnectivityManager$OnStartTetheringCallback;->()V,system-api,whitelist`](https://android.googlesource.com/platform/prebuilts/runtime/+/3d07e5c/appcompat/hiddenapi-flags.csv#123103) +* (since API 24) [`Landroid/net/ConnectivityManager$OnStartTetheringCallback;->onTetheringFailed()V,system-api,whitelist`](https://android.googlesource.com/platform/prebuilts/runtime/+/3d07e5c/appcompat/hiddenapi-flags.csv#123104) +* (since API 24) [`Landroid/net/ConnectivityManager$OnStartTetheringCallback;->onTetheringStarted()V,system-api,whitelist`](https://android.googlesource.com/platform/prebuilts/runtime/+/3d07e5c/appcompat/hiddenapi-flags.csv#123105) +* (since API 24) [`Landroid/net/ConnectivityManager;->getLastTetherError(Ljava/lang/String;)I,greylist`](https://android.googlesource.com/platform/prebuilts/runtime/+/3d07e5c/appcompat/hiddenapi-flags.csv#123309) +* (since API 24) [`Landroid/net/ConnectivityManager;->startTethering(IZLandroid/net/ConnectivityManager$OnStartTetheringCallback;Landroid/os/Handler;)V,system-api,whitelist`](https://android.googlesource.com/platform/prebuilts/runtime/+/3d07e5c/appcompat/hiddenapi-flags.csv#123408) +* (since API 24) [`Landroid/net/ConnectivityManager;->stopTethering(I)V,system-api,whitelist`](https://android.googlesource.com/platform/prebuilts/runtime/+/3d07e5c/appcompat/hiddenapi-flags.csv#123410) +* (since API 23) [`Landroid/net/wifi/WifiConfiguration;->apBand:I,greylist`](https://android.googlesource.com/platform/prebuilts/runtime/+/3d07e5c/appcompat/hiddenapi-flags.csv#131529) +* (since API 23) [`Landroid/net/wifi/WifiConfiguration;->apChannel:I,greylist`](https://android.googlesource.com/platform/prebuilts/runtime/+/3d07e5c/appcompat/hiddenapi-flags.csv#131530) +* [`Landroid/net/wifi/WifiManager;->getWifiApConfiguration()Landroid/net/wifi/WifiConfiguration;,system-api,whitelist`](https://android.googlesource.com/platform/prebuilts/runtime/+/3d07e5c/appcompat/hiddenapi-flags.csv#132289) +* [`Landroid/net/wifi/WifiManager;->setWifiApConfiguration(Landroid/net/wifi/WifiConfiguration;)Z,system-api,whitelist`](https://android.googlesource.com/platform/prebuilts/runtime/+/3d07e5c/appcompat/hiddenapi-flags.csv#132358) * (deprecated since API 26) `Landroid/net/wifi/WifiManager;->setWifiApEnabled(Landroid/net/wifi/WifiConfiguration;Z)Z` +* (prior to API 29) [`Landroid/net/wifi/p2p/WifiP2pGroup;->getNetworkId()I,greylist`](https://android.googlesource.com/platform/prebuilts/runtime/+/3d07e5c/appcompat/hiddenapi-flags.csv#134440) +* (prior to API 29) [`Landroid/net/wifi/p2p/WifiP2pGroupList;->getGroupList()Ljava/util/Collection;,greylist`](https://android.googlesource.com/platform/prebuilts/runtime/+/3d07e5c/appcompat/hiddenapi-flags.csv#134487) +* (prior to API 29) [`Landroid/net/wifi/p2p/WifiP2pManager;->deletePersistentGroup(Landroid/net/wifi/p2p/WifiP2pManager$Channel;ILandroid/net/wifi/p2p/WifiP2pManager$ActionListener;)V,greylist`](https://android.googlesource.com/platform/prebuilts/runtime/+/3d07e5c/appcompat/hiddenapi-flags.csv#134703) +* (prior to API 29) [`Landroid/net/wifi/p2p/WifiP2pManager;->requestPersistentGroupInfo(Landroid/net/wifi/p2p/WifiP2pManager$Channel;Landroid/net/wifi/p2p/WifiP2pManager$PersistentGroupInfoListener;)V,greylist`](https://android.googlesource.com/platform/prebuilts/runtime/+/3d07e5c/appcompat/hiddenapi-flags.csv#134728) +* (prior to API 29) [`Landroid/net/wifi/p2p/WifiP2pManager;->setWifiP2pChannels(Landroid/net/wifi/p2p/WifiP2pManager$Channel;IILandroid/net/wifi/p2p/WifiP2pManager$ActionListener;)V,greylist`](https://android.googlesource.com/platform/prebuilts/runtime/+/3d07e5c/appcompat/hiddenapi-flags.csv#134737) +* [`Landroid/net/wifi/p2p/WifiP2pManager;->startWps(Landroid/net/wifi/p2p/WifiP2pManager$Channel;Landroid/net/wifi/WpsInfo;Landroid/net/wifi/p2p/WifiP2pManager$ActionListener;)V,greylist`](https://android.googlesource.com/platform/prebuilts/runtime/+/3d07e5c/appcompat/hiddenapi-flags.csv#134738) +* (prior to API 29) [`Ljava/net/InetAddress;->parseNumericAddress(Ljava/lang/String;)Ljava/net/InetAddress;,core-platform-api,greylist-max-p`](https://android.googlesource.com/platform/prebuilts/runtime/+/3d07e5c/appcompat/hiddenapi-flags.csv#335306) Undocumented system configurations: @@ -134,21 +150,22 @@ Undocumented system configurations: Other: -* (since API 27) [`Landroid/provider/Settings$Global;->TETHER_OFFLOAD_DISABLED:Ljava/lang/String;,greylist-max-o`](https://android.googlesource.com/platform/prebuilts/runtime/+/aa21a6e/appcompat/hiddenapi-flags.csv#144760) is assumed to be `"tether_offload_disabled"`. +* (since API 29) `android.net.wifi.p2p.WifiP2pConfig` needs to be parcelized in a very specific order. +* (since API 27) [`Landroid/provider/Settings$Global;->TETHER_OFFLOAD_DISABLED:Ljava/lang/String;,greylist-max-o`](https://android.googlesource.com/platform/prebuilts/runtime/+/3d07e5c/appcompat/hiddenapi-flags.csv#158331) is assumed to be `"tether_offload_disabled"`. * (since API 27) `com.android.server.connectivity.tethering.OffloadHardwareInterface.DEFAULT_TETHER_OFFLOAD_DISABLED` is assumed to be 0. * Several constants in `ConnectivityManager` is assumed to be defined as in `TetheringManager.kt`; * Following broadcasts are assumed to be sticky: - - [`Landroid/net/ConnectivityManager;->ACTION_TETHER_STATE_CHANGED:Ljava/lang/String;,greylist`](https://android.googlesource.com/platform/prebuilts/runtime/+/aa21a6e/appcompat/hiddenapi-flags.csv#112743) is assumed to be `android.net.conn.TETHER_STATE_CHANGED`. - - [`Landroid/net/wifi/p2p/WifiP2pManager;->WIFI_P2P_PERSISTENT_GROUPS_CHANGED_ACTION:Ljava/lang/String;,greylist-max-o`](https://android.googlesource.com/platform/prebuilts/runtime/+/aa21a6e/appcompat/hiddenapi-flags.csv#123415) is assumed to be `android.net.wifi.p2p.PERSISTENT_GROUPS_CHANGED`; + - [`Landroid/net/ConnectivityManager;->ACTION_TETHER_STATE_CHANGED:Ljava/lang/String;,greylist`](https://android.googlesource.com/platform/prebuilts/runtime/+/3d07e5c/appcompat/hiddenapi-flags.csv#123163) is assumed to be `android.net.conn.TETHER_STATE_CHANGED`. + - (prior to API 29) [`Landroid/net/wifi/p2p/WifiP2pManager;->WIFI_P2P_PERSISTENT_GROUPS_CHANGED_ACTION:Ljava/lang/String;,greylist-max-o`](https://android.googlesource.com/platform/prebuilts/runtime/+/3d07e5c/appcompat/hiddenapi-flags.csv#134686) is assumed to be `android.net.wifi.p2p.PERSISTENT_GROUPS_CHANGED`; * Activity `com.android.settings/.Settings$TetherSettingsActivity` is assumed to be exported. -For `ip rule` priorities, `RULE_PRIORITY_SECURE_VPN` and `RULE_PRIORITY_TETHERING` is assumed to be 12000 and 18000 -respectively; `RULE_PRIORITY_DEFAULT_NETWORK` is assumed to be 22000 (or at least > 18000) for API 27-. +For `ip rule` priorities, `RULE_PRIORITY_SECURE_VPN` and `RULE_PRIORITY_TETHERING` is assumed to be 12000 and 18000 respectively; +(prior to API 24) `RULE_PRIORITY_DEFAULT_NETWORK` is assumed to be 22000 (or at least > 18000). DHCP server like `dnsmasq` is assumed to run and send DHCP packets as root. Undocumented system binaries are all bundled and executable: -* Since API 24: `iptables-save`; +* (since API 24) `iptables-save`; * `echo`; * `ip` (`link monitor neigh rule` with proper output format); * `ndc` (`ipfwd` with proper output format since API 23, `nat`); @@ -157,7 +174,7 @@ Undocumented system binaries are all bundled and executable: If some of these are unavailable, you can alternatively install a recent version (v1.28.1 or higher) of Busybox. -Wi-Fi driver `wpa_supplicant`: +(prior to API 29) Wi-Fi driver `wpa_supplicant`: * P2P configuration file is assumed to be saved to [`/data/vendor/wifi/wpa/p2p_supplicant.conf` or `/data/misc/wifi/p2p_supplicant.conf`](https://android.googlesource.com/platform/external/wpa_supplicant_8/+/0b4856b6dc451e290f1f64f6af17e010be78c073/wpa_supplicant/hidl/1.1/supplicant.cpp#26) and have reasonable format; * Android system is expected to restart `wpa_supplicant` after it crashes. diff --git a/build.gradle b/build.gradle index b6668a65..fe803682 100644 --- a/build.gradle +++ b/build.gradle @@ -12,10 +12,10 @@ buildscript { } } dependencies { - classpath 'com.android.tools.build:gradle:3.4.0' + classpath 'com.android.tools.build:gradle:3.5.0-beta04' classpath 'com.github.ben-manes:gradle-versions-plugin:0.21.0' classpath 'com.google.gms:google-services:4.2.0' - classpath 'io.fabric.tools:gradle:1.28.1' + classpath 'io.fabric.tools:gradle:1.29.0' classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlinVersion" } } diff --git a/detekt.yml b/detekt.yml index 305b961d..7c7e4559 100644 --- a/detekt.yml +++ b/detekt.yml @@ -363,7 +363,7 @@ style: OptionalUnit: active: true OptionalWhenBraces: - active: true + active: false PreferToOverPairSyntax: active: false ProtectedMemberInFinalClass: diff --git a/gradle.properties b/gradle.properties index 5066c426..d08d04cc 100644 --- a/gradle.properties +++ b/gradle.properties @@ -10,7 +10,6 @@ # Specifies the JVM arguments used for the daemon process. # The setting is particularly useful for tweaking memory settings. android.enableJetifier=true -android.enableR8=true android.enableR8.fullMode=true android.useAndroidX=true org.gradle.jvmargs=-Xmx1536m diff --git a/mobile/build.gradle b/mobile/build.gradle index c0c6d10e..4e2843eb 100644 --- a/mobile/build.gradle +++ b/mobile/build.gradle @@ -9,8 +9,7 @@ if (!getGradle().getStartParameter().getTaskRequests().toString().contains("Fdro } android { - buildToolsVersion "28.0.3" - compileSdkVersion 28 + compileSdkVersion 29 compileOptions { sourceCompatibility 1.8 targetCompatibility 1.8 @@ -18,10 +17,10 @@ android { defaultConfig { applicationId "be.mygod.vpnhotspot" minSdkVersion 21 - targetSdkVersion 28 + targetSdkVersion 29 resConfigs "ru", "zh-rCN" - versionCode 124 - versionName "2.3.5" + versionCode 204 + versionName '2.4.4' testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" javaCompileOptions { annotationProcessorOptions { @@ -65,39 +64,39 @@ androidExtensions { } def aux = [ - 'com.crashlytics.sdk.android:crashlytics:2.9.9', - 'com.google.firebase:firebase-core:16.0.8', + 'com.crashlytics.sdk.android:crashlytics:2.10.1', + 'com.google.firebase:firebase-core:16.0.9', ] -def lifecycleVersion = '2.0.0' -def roomVersion = '2.1.0-alpha07' +def lifecycleVersion = '2.1.0-beta01' +def roomVersion = '2.1.0-rc01' dependencies { kapt "androidx.room:room-compiler:$roomVersion" implementation fileTree(dir: 'libs', include: ['*.jar']) implementation 'androidx.browser:browser:1.0.0' - implementation 'androidx.core:core-ktx:1.0.1' + implementation 'androidx.core:core-ktx:1.1.0-rc01' implementation 'androidx.emoji:emoji:1.0.0' implementation "androidx.lifecycle:lifecycle-common-java8:$lifecycleVersion" implementation "androidx.lifecycle:lifecycle-extensions:$lifecycleVersion" + implementation "androidx.lifecycle:lifecycle-livedata-ktx:$lifecycleVersion" implementation "androidx.lifecycle:lifecycle-viewmodel-ktx:$lifecycleVersion" - implementation 'androidx.preference:preference:1.1.0-alpha04' + implementation 'androidx.preference:preference:1.1.0-beta01' implementation "androidx.room:room-ktx:$roomVersion" - implementation 'com.android.billingclient:billing:1.2.2' - implementation 'com.github.luongvo:BadgeView:1.1.5' + implementation 'com.android.billingclient:billing:2.0.1' implementation 'com.github.topjohnwu.libsu:core:2.5.0' - implementation 'com.google.android.material:material:1.0.0' + implementation 'com.google.android.material:material:1.1.0-alpha07' implementation 'com.jakewharton.timber:timber:4.7.1' implementation 'com.linkedin.dexmaker:dexmaker:2.25.0' implementation 'com.takisoft.preferencex:preferencex-simplemenu:1.0.0' - implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlinVersion" + implementation 'net.glxn.qrgen:android:2.0' + implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlinVersion" implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.2.1' for (dep in aux) { freedomImplementation dep googleImplementation dep } - testImplementation "androidx.arch.core:core-testing:$lifecycleVersion" testImplementation 'junit:junit:4.12' androidTestImplementation "androidx.room:room-testing:$roomVersion" - androidTestImplementation 'androidx.test:runner:1.1.1' - androidTestImplementation 'androidx.test.espresso:espresso-core:3.1.1' - androidTestImplementation 'androidx.test.ext:junit-ktx:1.1.0' + androidTestImplementation 'androidx.test:runner:1.2.0' + androidTestImplementation 'androidx.test.espresso:espresso-core:3.2.0' + androidTestImplementation 'androidx.test.ext:junit-ktx:1.1.1-beta01' } diff --git a/mobile/src/main/AndroidManifest.xml b/mobile/src/main/AndroidManifest.xml index a1ef8945..23b1a406 100644 --- a/mobile/src/main/AndroidManifest.xml +++ b/mobile/src/main/AndroidManifest.xml @@ -32,6 +32,8 @@ + @@ -39,6 +41,8 @@ + + = 24) { deviceStorage = DeviceStorageApp(this) - deviceStorage.moveSharedPreferencesFrom(this, PreferenceManager.getDefaultSharedPreferencesName(this)) + // alternative to PreferenceManager.getDefaultSharedPreferencesName(this) + deviceStorage.moveSharedPreferencesFrom(this, PreferenceManager(this).sharedPreferencesName) deviceStorage.moveDatabaseFrom(this, AppDatabase.DB_NAME) } else deviceStorage = this DebugHelper.init() @@ -48,10 +50,11 @@ class App : Application() { override fun onFailed(throwable: Throwable?) = Timber.d(throwable) }) }) + EBegFragment.init() if (DhcpWorkaround.shouldEnable) DhcpWorkaround.enable(true) } - override fun onConfigurationChanged(newConfig: Configuration?) { + override fun onConfigurationChanged(newConfig: Configuration) { super.onConfigurationChanged(newConfig) ServiceNotification.updateNotificationChannels() } @@ -69,6 +72,7 @@ class App : Application() { } val pref by lazy { PreferenceManager.getDefaultSharedPreferences(deviceStorage) } val connectivity by lazy { getSystemService()!! } + val clipboard by lazy { getSystemService()!! } val uiMode by lazy { getSystemService()!! } val wifi by lazy { getSystemService()!! } diff --git a/mobile/src/main/java/be/mygod/vpnhotspot/EBegFragment.kt b/mobile/src/main/java/be/mygod/vpnhotspot/EBegFragment.kt index e8590351..f8c361f0 100644 --- a/mobile/src/main/java/be/mygod/vpnhotspot/EBegFragment.kt +++ b/mobile/src/main/java/be/mygod/vpnhotspot/EBegFragment.kt @@ -1,40 +1,66 @@ package be.mygod.vpnhotspot -import android.content.DialogInterface import android.os.Bundle -import android.os.Parcelable import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.widget.ArrayAdapter import android.widget.Button import android.widget.Spinner -import androidx.annotation.StringRes -import androidx.appcompat.app.AlertDialog import androidx.appcompat.app.AppCompatDialogFragment +import be.mygod.vpnhotspot.App.Companion.app import be.mygod.vpnhotspot.util.launchUrl import be.mygod.vpnhotspot.widget.SmartSnackbar import com.android.billingclient.api.* -import kotlinx.android.parcel.Parcelize import kotlinx.android.synthetic.main.fragment_ebeg.view.* import timber.log.Timber /** * Based on: https://github.com/PrivacyApps/donations/blob/747d36a18433c7e9329691054122a8ad337a62d2/Donations/src/main/java/org/sufficientlysecure/donations/DonationsFragment.java */ -class EBegFragment : AppCompatDialogFragment(), PurchasesUpdatedListener, BillingClientStateListener, - SkuDetailsResponseListener, ConsumeResponseListener { - @Parcelize - data class MessageArg(@StringRes val title: Int, @StringRes val message: Int) : Parcelable - class MessageDialogFragment : AlertDialogFragment() { - override fun AlertDialog.Builder.prepare(listener: DialogInterface.OnClickListener) { - setTitle(arg.title) - setMessage(arg.message) - setNeutralButton(R.string.donations__button_close, null) +class EBegFragment : AppCompatDialogFragment(), SkuDetailsResponseListener { + companion object : BillingClientStateListener, PurchasesUpdatedListener, ConsumeResponseListener { + private lateinit var billingClient: BillingClient + + fun init() { + billingClient = BillingClient.newBuilder(app).apply { + enablePendingPurchases() + }.setListener(this).build().also { it.startConnection(this) } + } + + override fun onBillingSetupFinished(billingResult: BillingResult?) { + if (billingResult?.responseCode == BillingClient.BillingResponseCode.OK) { + billingClient.queryPurchases(BillingClient.SkuType.INAPP).apply { + if (responseCode == BillingClient.BillingResponseCode.OK) { + onPurchasesUpdated(this.billingResult, purchasesList) + } + } + } else Timber.e("onBillingSetupFinished: ${billingResult?.responseCode}") + } + + override fun onBillingServiceDisconnected() { + Timber.e("onBillingServiceDisconnected") + billingClient.startConnection(this) + } + + override fun onPurchasesUpdated(billingResult: BillingResult?, purchases: MutableList?) { + if (billingResult?.responseCode == BillingClient.BillingResponseCode.OK && purchases != null) { + // directly consume in-app purchase, so that people can donate multiple times + for (purchase in purchases) if (purchase.purchaseState == Purchase.PurchaseState.PURCHASED) { + billingClient.consumeAsync(ConsumeParams.newBuilder().apply { + setPurchaseToken(purchase.purchaseToken) + }.build(), this) + } + } else Timber.e("onPurchasesUpdated: ${billingResult?.responseCode}") + } + + override fun onConsumeResponse(billingResult: BillingResult?, purchaseToken: String?) { + if (billingResult?.responseCode == BillingClient.BillingResponseCode.OK) { + SmartSnackbar.make(R.string.donations__thanks_dialog).show() + } else Timber.e("onConsumeResponse: ${billingResult?.responseCode}") } } - private lateinit var billingClient: BillingClient private lateinit var googleSpinner: Spinner private var skus: MutableList? = null set(value) { @@ -53,62 +79,27 @@ class EBegFragment : AppCompatDialogFragment(), PurchasesUpdatedListener, Billin super.onViewCreated(view, savedInstanceState) dialog!!.setTitle(R.string.settings_misc_donate) googleSpinner = view.donations__google_android_market_spinner - onBillingServiceDisconnected() + billingClient.querySkuDetailsAsync( + SkuDetailsParams.newBuilder().apply { + setSkusList(listOf("donate001", "donate002", "donate005", "donate010", "donate020", "donate050", + "donate100", "donate200", "donatemax")) + setType(BillingClient.SkuType.INAPP) + }.build(), this) view.donations__google_android_market_donate_button.setOnClickListener { val sku = skus?.getOrNull(googleSpinner.selectedItemPosition) - if (sku == null) { - openDialog(R.string.donations__google_android_market_not_supported_title, - R.string.donations__google_android_market_not_supported) - } else billingClient.launchBillingFlow(requireActivity(), BillingFlowParams.newBuilder() - .setSkuDetails(sku).build()) + if (sku != null) billingClient.launchBillingFlow(requireActivity(), BillingFlowParams.newBuilder().apply { + setSkuDetails(sku) + }.build()) else SmartSnackbar.make(R.string.donations__google_android_market_not_supported).show() } @Suppress("ConstantConditionIf") if (BuildConfig.DONATIONS) (view.donations__more_stub.inflate() as Button) .setOnClickListener { requireContext().launchUrl("https://mygod.be/donate/") } } - private fun openDialog(@StringRes title: Int, @StringRes message: Int) { - val fragmentManager = fragmentManager - if (fragmentManager == null) SmartSnackbar.make(message).show() else try { - MessageDialogFragment().withArg(MessageArg(title, message)).show(fragmentManager, "MessageDialogFragment") - } catch (e: IllegalStateException) { - SmartSnackbar.make(message).show() + override fun onSkuDetailsResponse(billingResult: BillingResult?, skuDetailsList: MutableList?) { + if (billingResult?.responseCode == BillingClient.BillingResponseCode.OK) skus = skuDetailsList else { + Timber.e("onSkuDetailsResponse: ${billingResult?.responseCode}") + SmartSnackbar.make(R.string.donations__google_android_market_not_supported).show() } } - - override fun onBillingServiceDisconnected() { - skus = null - billingClient = BillingClient.newBuilder(context ?: return).setListener(this).build() - .also { it.startConnection(this) } - } - - override fun onBillingSetupFinished(responseCode: Int) { - if (responseCode == BillingClient.BillingResponse.OK) { - billingClient.querySkuDetailsAsync( - SkuDetailsParams.newBuilder().apply { - setSkusList(listOf("donate001", "donate002", "donate005", "donate010", "donate020", "donate050", - "donate100", "donate200", "donatemax")) - setType(BillingClient.SkuType.INAPP) - }.build(), this) - } else Timber.e("onBillingSetupFinished: $responseCode") - } - - override fun onSkuDetailsResponse(responseCode: Int, skuDetailsList: MutableList?) { - if (responseCode == BillingClient.BillingResponse.OK) skus = skuDetailsList - else Timber.e("onSkuDetailsResponse: $responseCode") - } - - override fun onPurchasesUpdated(responseCode: Int, purchases: MutableList?) { - if (responseCode == BillingClient.BillingResponse.OK && purchases != null) { - // directly consume in-app purchase, so that people can donate multiple times - purchases.forEach { billingClient.consumeAsync(it.purchaseToken, this) } - } else Timber.e("onPurchasesUpdated: $responseCode") - } - - override fun onConsumeResponse(responseCode: Int, purchaseToken: String?) { - if (responseCode == BillingClient.BillingResponse.OK) { - openDialog(R.string.donations__thanks_dialog_title, R.string.donations__thanks_dialog) - dismissAllowingStateLoss() - } else Timber.e("onConsumeResponse: $responseCode") - } } diff --git a/mobile/src/main/java/be/mygod/vpnhotspot/LocalOnlyHotspotService.kt b/mobile/src/main/java/be/mygod/vpnhotspot/LocalOnlyHotspotService.kt index 2277ccc4..96e6e0b3 100644 --- a/mobile/src/main/java/be/mygod/vpnhotspot/LocalOnlyHotspotService.kt +++ b/mobile/src/main/java/be/mygod/vpnhotspot/LocalOnlyHotspotService.kt @@ -41,7 +41,7 @@ class LocalOnlyHotspotService : IpNeighbourMonitoringService() { private var routingManager: RoutingManager? = null private var receiverRegistered = false private val receiver = broadcastReceiver { _, intent -> - val ifaces = intent.localOnlyTetheredIfaces + val ifaces = intent.localOnlyTetheredIfaces ?: return@broadcastReceiver DebugHelper.log(TAG, "onTetherStateChangedLocked: $ifaces") check(ifaces.size <= 1) val iface = ifaces.singleOrNull() diff --git a/mobile/src/main/java/be/mygod/vpnhotspot/MainActivity.kt b/mobile/src/main/java/be/mygod/vpnhotspot/MainActivity.kt index 4b859996..ff2328c1 100644 --- a/mobile/src/main/java/be/mygod/vpnhotspot/MainActivity.kt +++ b/mobile/src/main/java/be/mygod/vpnhotspot/MainActivity.kt @@ -2,28 +2,25 @@ package be.mygod.vpnhotspot import android.content.Intent import android.os.Bundle -import android.view.Gravity import android.view.MenuItem import androidx.appcompat.app.AppCompatActivity import androidx.core.content.ContextCompat import androidx.databinding.DataBindingUtil import androidx.fragment.app.Fragment -import androidx.lifecycle.Observer import androidx.lifecycle.ViewModelProviders import androidx.lifecycle.get +import androidx.lifecycle.observe import be.mygod.vpnhotspot.client.ClientViewModel import be.mygod.vpnhotspot.client.ClientsFragment import be.mygod.vpnhotspot.databinding.ActivityMainBinding import be.mygod.vpnhotspot.manage.TetheringFragment +import be.mygod.vpnhotspot.net.wifi.WifiDoubleLock import be.mygod.vpnhotspot.util.ServiceForegroundConnector import be.mygod.vpnhotspot.widget.SmartSnackbar -import com.google.android.material.bottomnavigation.BottomNavigationMenuView import com.google.android.material.bottomnavigation.BottomNavigationView -import q.rorbin.badgeview.QBadgeView class MainActivity : AppCompatActivity(), BottomNavigationView.OnNavigationItemSelectedListener { private lateinit var binding: ActivityMainBinding - private lateinit var badge: QBadgeView override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -31,16 +28,17 @@ class MainActivity : AppCompatActivity(), BottomNavigationView.OnNavigationItemS binding.lifecycleOwner = this binding.navigation.setOnNavigationItemSelectedListener(this) if (savedInstanceState == null) displayFragment(TetheringFragment()) - badge = QBadgeView(this) - badge.bindTarget((binding.navigation.getChildAt(0) as BottomNavigationMenuView).getChildAt(1)) - badge.badgeBackgroundColor = ContextCompat.getColor(this, R.color.colorSecondary) - badge.badgeTextColor = ContextCompat.getColor(this, R.color.primary_text_default_material_light) - badge.badgeGravity = Gravity.TOP or Gravity.CENTER_HORIZONTAL - badge.setGravityOffset(16f, 0f, true) val model = ViewModelProviders.of(this).get() if (RepeaterService.supported) ServiceForegroundConnector(this, model, RepeaterService::class) - model.clients.observe(this, Observer { badge.badgeNumber = it.size }) + model.clients.observe(this) { + if (it.isNotEmpty()) binding.navigation.showBadge(R.id.navigation_clients).apply { + backgroundColor = ContextCompat.getColor(this@MainActivity, R.color.colorSecondary) + badgeTextColor = ContextCompat.getColor(this@MainActivity, R.color.primary_text_default_material_light) + number = it.size + } else binding.navigation.removeBadge(R.id.navigation_clients) + } SmartSnackbar.Register(lifecycle, binding.fragmentHolder) + WifiDoubleLock.ActivityListener(this) } override fun onNavigationItemSelected(item: MenuItem) = when (item.itemId) { diff --git a/mobile/src/main/java/be/mygod/vpnhotspot/RepeaterService.kt b/mobile/src/main/java/be/mygod/vpnhotspot/RepeaterService.kt index 021caa5d..04356743 100644 --- a/mobile/src/main/java/be/mygod/vpnhotspot/RepeaterService.kt +++ b/mobile/src/main/java/be/mygod/vpnhotspot/RepeaterService.kt @@ -1,18 +1,17 @@ package be.mygod.vpnhotspot +import android.annotation.SuppressLint import android.app.Service import android.content.Intent import android.content.SharedPreferences import android.content.res.Configuration -import android.net.NetworkInfo -import android.net.wifi.p2p.WifiP2pDevice -import android.net.wifi.p2p.WifiP2pGroup -import android.net.wifi.p2p.WifiP2pInfo -import android.net.wifi.p2p.WifiP2pManager +import android.net.wifi.WpsInfo +import android.net.wifi.p2p.* import android.os.Build import android.os.Handler import android.os.Looper import androidx.annotation.StringRes +import androidx.core.content.edit import androidx.core.content.getSystemService import be.mygod.vpnhotspot.App.Companion.app import be.mygod.vpnhotspot.net.wifi.WifiP2pManagerHelper @@ -21,10 +20,8 @@ import be.mygod.vpnhotspot.net.wifi.WifiP2pManagerHelper.netId import be.mygod.vpnhotspot.net.wifi.WifiP2pManagerHelper.requestPersistentGroupInfo import be.mygod.vpnhotspot.net.wifi.WifiP2pManagerHelper.setWifiP2pChannels import be.mygod.vpnhotspot.net.wifi.WifiP2pManagerHelper.startWps -import be.mygod.vpnhotspot.util.StickyEvent0 -import be.mygod.vpnhotspot.util.StickyEvent1 -import be.mygod.vpnhotspot.util.broadcastReceiver -import be.mygod.vpnhotspot.util.intentFilter +import be.mygod.vpnhotspot.net.wifi.configuration.channelToFrequency +import be.mygod.vpnhotspot.util.* import be.mygod.vpnhotspot.widget.SmartSnackbar import timber.log.Timber import java.lang.reflect.InvocationTargetException @@ -35,7 +32,14 @@ import java.lang.reflect.InvocationTargetException class RepeaterService : Service(), WifiP2pManager.ChannelListener, SharedPreferences.OnSharedPreferenceChangeListener { companion object { private const val TAG = "RepeaterService" - const val KEY_OPERATING_CHANNEL = "service.repeater.oc" + private const val KEY_NETWORK_NAME = "service.repeater.networkName" + private const val KEY_PASSPHRASE = "service.repeater.passphrase" + private const val KEY_OPERATING_BAND = "service.repeater.band" + private const val KEY_OPERATING_CHANNEL = "service.repeater.oc" + /** + * Placeholder for bypassing networkName check. + */ + private const val PLACEHOLDER_NETWORK_NAME = "DIRECT-00-VPNHotspot" /** * This is only a "ServiceConnection" to system service and its impact on system is minimal. @@ -49,12 +53,24 @@ class RepeaterService : Service(), WifiP2pManager.ChannelListener, SharedPrefere } } val supported get() = p2pManager != null + @Deprecated("Not initialized and no use at all since API 29") var persistentSupported = false - val operatingChannel: Int get() { - val result = app.pref.getString(KEY_OPERATING_CHANNEL, null)?.toIntOrNull() ?: 0 - return if (result in 1..165) result else 0 - } + var networkName: String? + get() = app.pref.getString(KEY_NETWORK_NAME, null) + set(value) = app.pref.edit { putString(KEY_NETWORK_NAME, value) } + var passphrase: String? + get() = app.pref.getString(KEY_PASSPHRASE, null) + set(value) = app.pref.edit { putString(KEY_PASSPHRASE, value) } + var operatingBand: Int + @SuppressLint("InlinedApi") get() = app.pref.getInt(KEY_OPERATING_BAND, WifiP2pConfig.GROUP_OWNER_BAND_AUTO) + set(value) = app.pref.edit { putInt(KEY_OPERATING_BAND, value) } + var operatingChannel: Int + get() { + val result = app.pref.getString(KEY_OPERATING_CHANNEL, null)?.toIntOrNull() ?: 0 + return if (result in 1..165) result else 0 + } + set(value) = app.pref.edit { putString(KEY_OPERATING_CHANNEL, value.toString()) } } enum class Status { @@ -71,16 +87,16 @@ class RepeaterService : Service(), WifiP2pManager.ChannelListener, SharedPrefere groupChanged(value) } val groupChanged = StickyEvent1 { group } + @Deprecated("Not initialized and no use at all since API 29") var thisDevice: WifiP2pDevice? = null - @Deprecated("WPS was deprecated RIP") fun startWps(pin: String? = null) { val channel = channel if (channel == null) SmartSnackbar.make(R.string.repeater_failure_disconnected).show() - else @Suppress("DEPRECATION") if (active) p2pManager.startWps(channel, android.net.wifi.WpsInfo().apply { - setup = if (pin == null) android.net.wifi.WpsInfo.PBC else { + else if (active) p2pManager.startWps(channel, WpsInfo().apply { + setup = if (pin == null) WpsInfo.PBC else { this.pin = pin - android.net.wifi.WpsInfo.KEYPAD + WpsInfo.KEYPAD } }, object : WifiP2pManager.ActionListener { override fun onSuccess() = SmartSnackbar.make( @@ -94,18 +110,6 @@ class RepeaterService : Service(), WifiP2pManager.ChannelListener, SharedPrefere fun shutdown() { if (active) removeGroup() } - - fun resetCredentials() { - val channel = channel - if (channel == null) SmartSnackbar.make(R.string.repeater_failure_disconnected).show() - else p2pManager.deletePersistentGroup(channel, (group ?: return).netId, - object : WifiP2pManager.ActionListener { - override fun onSuccess() = SmartSnackbar.make(R.string.repeater_reset_credentials_success) - .shortToast().show() - override fun onFailure(reason: Int) = SmartSnackbar.make( - formatReason(R.string.repeater_reset_credentials_failure, reason)).show() - }) - } } private val p2pManager get() = RepeaterService.p2pManager!! @@ -119,11 +123,12 @@ class RepeaterService : Service(), WifiP2pManager.ChannelListener, SharedPrefere if (intent.getIntExtra(WifiP2pManager.EXTRA_WIFI_STATE, 0) == WifiP2pManager.WIFI_P2P_STATE_DISABLED) clean() // ignore P2P enabled WifiP2pManager.WIFI_P2P_CONNECTION_CHANGED_ACTION -> onP2pConnectionChanged( - intent.getParcelableExtra(WifiP2pManager.EXTRA_WIFI_P2P_INFO), - intent.getParcelableExtra(WifiP2pManager.EXTRA_NETWORK_INFO), - intent.getParcelableExtra(WifiP2pManager.EXTRA_WIFI_P2P_GROUP)) + intent.getParcelableExtra(WifiP2pManager.EXTRA_WIFI_P2P_INFO)!!, + intent.getParcelableExtra(WifiP2pManager.EXTRA_WIFI_P2P_GROUP)!!) } } + @Deprecated("No longer used since API 29") + @Suppress("DEPRECATION") private val deviceListener = broadcastReceiver { _, intent -> when (intent.action) { WifiP2pManager.WIFI_P2P_THIS_DEVICE_CHANGED_ACTION -> binder.thisDevice = @@ -132,6 +137,7 @@ class RepeaterService : Service(), WifiP2pManager.ChannelListener, SharedPrefere } } private var routingManager: RoutingManager? = null + private var persistNextGroup = false var status = Status.IDLE private set(value) { @@ -154,13 +160,17 @@ class RepeaterService : Service(), WifiP2pManager.ChannelListener, SharedPrefere override fun onCreate() { super.onCreate() onChannelDisconnected() - registerReceiver(deviceListener, intentFilter(WifiP2pManager.WIFI_P2P_THIS_DEVICE_CHANGED_ACTION, - WifiP2pManagerHelper.WIFI_P2P_PERSISTENT_GROUPS_CHANGED_ACTION)) - app.pref.registerOnSharedPreferenceChangeListener(this) + if (Build.VERSION.SDK_INT < 29) @Suppress("DEPRECATION") { + registerReceiver(deviceListener, intentFilter(WifiP2pManager.WIFI_P2P_THIS_DEVICE_CHANGED_ACTION, + WifiP2pManagerHelper.WIFI_P2P_PERSISTENT_GROUPS_CHANGED_ACTION)) + app.pref.registerOnSharedPreferenceChangeListener(this) + } } override fun onBind(intent: Intent) = binder + @Deprecated("No longer used since API 29") + @Suppress("DEPRECATION") private fun setOperatingChannel(oc: Int = operatingChannel) = try { val channel = channel if (channel == null) SmartSnackbar.make(R.string.repeater_failure_disconnected).show() @@ -183,17 +193,21 @@ class RepeaterService : Service(), WifiP2pManager.ChannelListener, SharedPrefere channel = null if (status != Status.DESTROYED) try { channel = p2pManager.initialize(this, Looper.getMainLooper(), this) - setOperatingChannel() + if (Build.VERSION.SDK_INT < 29) @Suppress("DEPRECATION") setOperatingChannel() } catch (e: RuntimeException) { Timber.w(e) handler.postDelayed(this::onChannelDisconnected, 1000) } } + @Deprecated("No longer used since API 29") + @Suppress("DEPRECATION") override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences?, key: String?) { if (key == KEY_OPERATING_CHANNEL) setOperatingChannel() } + @Deprecated("No longer used since API 29") + @Suppress("DEPRECATION") private fun onPersistentGroupsChanged() { val channel = channel ?: return val device = binder.thisDevice ?: return @@ -232,34 +246,86 @@ class RepeaterService : Service(), WifiP2pManager.ChannelListener, SharedPrefere registerReceiver(receiver, intentFilter(WifiP2pManager.WIFI_P2P_STATE_CHANGED_ACTION, WifiP2pManager.WIFI_P2P_CONNECTION_CHANGED_ACTION)) receiverRegistered = true - p2pManager.requestGroupInfo(channel) { - when { - it == null -> doStart() - it.isGroupOwner -> if (routingManager == null) doStart(it) - else -> { - Timber.i("Removing old group ($it)") - p2pManager.removeGroup(channel, object : WifiP2pManager.ActionListener { - override fun onSuccess() = doStart() - override fun onFailure(reason: Int) = - startFailure(formatReason(R.string.repeater_remove_old_group_failure, reason)) - }) + try { + p2pManager.requestGroupInfo(channel) { + when { + it == null -> doStart() + it.isGroupOwner -> if (routingManager == null) doStart(it) + else -> { + Timber.i("Removing old group ($it)") + p2pManager.removeGroup(channel, object : WifiP2pManager.ActionListener { + override fun onSuccess() = doStart() + override fun onFailure(reason: Int) = + startFailure(formatReason(R.string.repeater_remove_old_group_failure, reason)) + }) + } } } + } catch (e: SecurityException) { + Timber.w(e) + startFailure(e.readableMessage) } return START_NOT_STICKY } /** * startService Step 2 (if a group isn't already available) */ - private fun doStart() = p2pManager.createGroup(channel, object : WifiP2pManager.ActionListener { - override fun onFailure(reason: Int) = startFailure(formatReason(R.string.repeater_create_group_failure, reason)) - override fun onSuccess() { } // wait for WIFI_P2P_CONNECTION_CHANGED_ACTION to fire to go to step 3 - }) + private fun doStart() { + val listener = object : WifiP2pManager.ActionListener { + override fun onFailure(reason: Int) { + startFailure(formatReason(R.string.repeater_create_group_failure, reason)) + } + override fun onSuccess() { } // wait for WIFI_P2P_CONNECTION_CHANGED_ACTION to fire to go to step 3 + } + val channel = channel ?: return listener.onFailure(WifiP2pManager.BUSY) + val networkName = networkName + val passphrase = passphrase + try { + if (Build.VERSION.SDK_INT < 29 || networkName == null || passphrase == null) { + persistNextGroup = true + p2pManager.createGroup(channel, listener) + } else p2pManager.createGroup(channel, WifiP2pConfig.Builder().apply { + setNetworkName(PLACEHOLDER_NETWORK_NAME) + setPassphrase(passphrase) + operatingChannel.let { oc -> + if (oc == 0) setGroupOperatingBand(operatingBand) + else setGroupOperatingFrequency(channelToFrequency(oc)) + } + }.build().run { + useParcel { p -> + p.writeParcelable(this, 0) + val end = p.dataPosition() + p.setDataPosition(0) + val creator = p.readString() + val deviceAddress = p.readString() + val wps = p.readParcelable(javaClass.classLoader) + val long = p.readLong() + check(p.readString() == PLACEHOLDER_NETWORK_NAME) + check(p.readString() == passphrase) + val int = p.readInt() + check(p.dataPosition() == end) + p.setDataPosition(0) + p.writeString(creator) + p.writeString(deviceAddress) + p.writeParcelable(wps, 0) + p.writeLong(long) + p.writeString(networkName) + p.writeString(passphrase) + p.writeInt(int) + p.setDataPosition(0) + p.readParcelable(javaClass.classLoader) + } + }, listener) + } catch (e: SecurityException) { + Timber.w(e) + startFailure(e.readableMessage) + } + } /** * Used during step 2, also called when connection changed */ - private fun onP2pConnectionChanged(info: WifiP2pInfo, net: NetworkInfo?, group: WifiP2pGroup) { - DebugHelper.log(TAG, "P2P connection changed: $info\n$net\n$group") + private fun onP2pConnectionChanged(info: WifiP2pInfo, group: WifiP2pGroup) { + DebugHelper.log(TAG, "P2P connection changed: $info\n$group") when { !info.groupFormed || !info.isGroupOwner || !group.isGroupOwner -> { if (routingManager != null) clean() // P2P shutdown, else other groups changing before start, ignore @@ -276,6 +342,11 @@ class RepeaterService : Service(), WifiP2pManager.ChannelListener, SharedPrefere */ private fun doStart(group: WifiP2pGroup) { binder.group = group + if (persistNextGroup) { + networkName = group.networkName + passphrase = group.passphrase + persistNextGroup = false + } check(routingManager == null) routingManager = RoutingManager.LocalOnly(this, group.`interface`!!).apply { start() } status = Status.ACTIVE @@ -320,8 +391,10 @@ class RepeaterService : Service(), WifiP2pManager.ChannelListener, SharedPrefere handler.removeCallbacksAndMessages(null) if (status != Status.IDLE) binder.shutdown() clean() // force clean to prevent leakage - app.pref.unregisterOnSharedPreferenceChangeListener(this) - unregisterReceiver(deviceListener) + if (Build.VERSION.SDK_INT < 29) @Suppress("DEPRECATION") { + app.pref.unregisterOnSharedPreferenceChangeListener(this) + unregisterReceiver(deviceListener) + } status = Status.DESTROYED if (Build.VERSION.SDK_INT >= 27) channel?.close() super.onDestroy() diff --git a/mobile/src/main/java/be/mygod/vpnhotspot/SettingsPreferenceFragment.kt b/mobile/src/main/java/be/mygod/vpnhotspot/SettingsPreferenceFragment.kt index 5a4faf26..e1b5986f 100644 --- a/mobile/src/main/java/be/mygod/vpnhotspot/SettingsPreferenceFragment.kt +++ b/mobile/src/main/java/be/mygod/vpnhotspot/SettingsPreferenceFragment.kt @@ -12,6 +12,7 @@ import be.mygod.vpnhotspot.App.Companion.app import be.mygod.vpnhotspot.net.Routing.Companion.IPTABLES import be.mygod.vpnhotspot.net.monitor.IpMonitor import be.mygod.vpnhotspot.net.monitor.UpstreamMonitor +import be.mygod.vpnhotspot.net.wifi.WifiDoubleLock import be.mygod.vpnhotspot.preference.AlwaysAutoCompleteEditTextPreferenceDialogFragmentCompat import be.mygod.vpnhotspot.preference.SharedPreferenceDataStore import be.mygod.vpnhotspot.util.RootSession @@ -26,6 +27,7 @@ import java.net.SocketException class SettingsPreferenceFragment : PreferenceFragmentCompat() { override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { + WifiDoubleLock.mode = WifiDoubleLock.mode // handle complicated default value and possible system upgrades preferenceManager.preferenceDataStore = SharedPreferenceDataStore(app.pref) RoutingManager.masqueradeMode = RoutingManager.masqueradeMode // flush default value addPreferencesFromResource(R.xml.pref_settings) diff --git a/mobile/src/main/java/be/mygod/vpnhotspot/TetheringService.kt b/mobile/src/main/java/be/mygod/vpnhotspot/TetheringService.kt index 00ff8fcf..7c0b7552 100644 --- a/mobile/src/main/java/be/mygod/vpnhotspot/TetheringService.kt +++ b/mobile/src/main/java/be/mygod/vpnhotspot/TetheringService.kt @@ -48,7 +48,7 @@ class TetheringService : IpNeighbourMonitoringService() { private val receiver = broadcastReceiver { _, intent -> synchronized(downstreams) { val toRemove = downstreams.toMutableMap() // make a copy - for (iface in intent.tetheredIfaces) { + for (iface in intent.tetheredIfaces ?: return@synchronized) { val downstream = toRemove.remove(iface) ?: continue if (downstream.monitor) downstream.start() } @@ -90,7 +90,7 @@ class TetheringService : IpNeighbourMonitoringService() { if (start()) check(downstreams.put(iface, this) == null) else destroy() } } - intent.getStringExtra(EXTRA_ADD_INTERFACE_MONITOR)?.let { iface -> + intent.getStringExtra(EXTRA_ADD_INTERFACE_MONITOR)?.also { iface -> val downstream = downstreams[iface] if (downstream == null) Downstream(this, iface, true).apply { start() @@ -98,7 +98,7 @@ class TetheringService : IpNeighbourMonitoringService() { downstreams[iface] = this } else downstream.monitor = true } - downstreams.remove(intent.getStringExtra(EXTRA_REMOVE_INTERFACE))?.destroy() + intent.getStringExtra(EXTRA_REMOVE_INTERFACE)?.also { downstreams.remove(it)?.destroy() } updateNotification() // call this first just in case we are shutting down immediately onDownstreamsChangedLocked() } else if (downstreams.isEmpty()) stopSelf(startId) diff --git a/mobile/src/main/java/be/mygod/vpnhotspot/client/Client.kt b/mobile/src/main/java/be/mygod/vpnhotspot/client/Client.kt index 783682dc..499c4134 100644 --- a/mobile/src/main/java/be/mygod/vpnhotspot/client/Client.kt +++ b/mobile/src/main/java/be/mygod/vpnhotspot/client/Client.kt @@ -3,7 +3,7 @@ package be.mygod.vpnhotspot.client import android.text.SpannableStringBuilder import android.text.Spanned import android.text.style.StrikethroughSpan -import androidx.lifecycle.Transformations +import androidx.lifecycle.map import androidx.recyclerview.widget.DiffUtil import be.mygod.vpnhotspot.App.Companion.app import be.mygod.vpnhotspot.R @@ -27,7 +27,7 @@ open class Client(val mac: Long, val iface: String) { val ip = TreeMap(InetAddressComparator) val macString by lazy { mac.macToString() } - private val record = AppDatabase.instance.clientRecordDao.lookupSync(mac) + private val record = AppDatabase.instance.clientRecordDao.lookupOrDefaultSync(mac) private val macIface get() = SpannableStringBuilder(makeMacSpan(macString)).apply { append('%') append(iface) @@ -37,24 +37,22 @@ open class Client(val mac: Long, val iface: String) { val blocked get() = record.value?.blocked == true open val icon get() = TetherType.ofInterface(iface).icon - val title = Transformations.map(record) { record -> + val title = record.map { record -> /** * we hijack the get title process to check if we need to perform MacLookup, * as record might not be initialized in other more appropriate places */ - SpannableStringBuilder(if (record?.nickname.isNullOrEmpty()) { - if (record?.macLookupPending != false) MacLookup.perform(mac) + SpannableStringBuilder(if (record.nickname.isEmpty()) { + if (record.macLookupPending) MacLookup.perform(mac) macIface - } else emojize(record?.nickname)).apply { - if (record?.blocked == true) { - setSpan(StrikethroughSpan(), 0, length, Spanned.SPAN_INCLUSIVE_INCLUSIVE) - } + } else emojize(record.nickname)).apply { + if (record.blocked) setSpan(StrikethroughSpan(), 0, length, Spanned.SPAN_INCLUSIVE_INCLUSIVE) } } - val titleSelectable = Transformations.map(record) { it?.nickname.isNullOrEmpty() } - val description = Transformations.map(record) { record -> + val titleSelectable = record.map { it.nickname.isEmpty() } + val description = record.map { record -> SpannableStringBuilder().apply { - if (!record?.nickname.isNullOrEmpty()) appendln(macIface) + if (record.nickname.isNotEmpty()) appendln(macIface) ip.entries.forEach { (ip, state) -> append(makeIpSpan(ip)) appendln(app.getText(when (state) { diff --git a/mobile/src/main/java/be/mygod/vpnhotspot/client/ClientViewModel.kt b/mobile/src/main/java/be/mygod/vpnhotspot/client/ClientViewModel.kt index 7983ea25..09c7c479 100644 --- a/mobile/src/main/java/be/mygod/vpnhotspot/client/ClientViewModel.kt +++ b/mobile/src/main/java/be/mygod/vpnhotspot/client/ClientViewModel.kt @@ -20,7 +20,8 @@ import be.mygod.vpnhotspot.util.broadcastReceiver class ClientViewModel : ViewModel(), ServiceConnection, IpNeighbourMonitor.Callback { private var tetheredInterfaces = emptySet() private val receiver = broadcastReceiver { _, intent -> - tetheredInterfaces = intent.tetheredIfaces.toSet() + intent.localOnlyTetheredIfaces + tetheredInterfaces = (intent.tetheredIfaces ?: return@broadcastReceiver).toSet() + + (intent.localOnlyTetheredIfaces ?: return@broadcastReceiver) populateClients() } diff --git a/mobile/src/main/java/be/mygod/vpnhotspot/client/ClientsFragment.kt b/mobile/src/main/java/be/mygod/vpnhotspot/client/ClientsFragment.kt index de9d3b51..b9549ab6 100644 --- a/mobile/src/main/java/be/mygod/vpnhotspot/client/ClientsFragment.kt +++ b/mobile/src/main/java/be/mygod/vpnhotspot/client/ClientsFragment.kt @@ -17,9 +17,9 @@ import androidx.appcompat.widget.PopupMenu import androidx.databinding.BaseObservable import androidx.databinding.DataBindingUtil import androidx.fragment.app.Fragment -import androidx.lifecycle.Observer import androidx.lifecycle.ViewModelProviders import androidx.lifecycle.get +import androidx.lifecycle.observe import androidx.recyclerview.widget.DefaultItemAnimator import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.ListAdapter @@ -219,8 +219,9 @@ class ClientsFragment : Fragment() { binding.swipeRefresher.setOnRefreshListener { IpNeighbourMonitor.instance?.flush() } - ViewModelProviders.of(requireActivity()).get().clients.observe(this, - Observer { adapter.submitList(it.toMutableList()) }) + ViewModelProviders.of(requireActivity()).get().clients.observe(this) { + adapter.submitList(it.toMutableList()) + } return binding.root } diff --git a/mobile/src/main/java/be/mygod/vpnhotspot/client/MacLookup.kt b/mobile/src/main/java/be/mygod/vpnhotspot/client/MacLookup.kt index 5f7ed771..cbcbcf0c 100644 --- a/mobile/src/main/java/be/mygod/vpnhotspot/client/MacLookup.kt +++ b/mobile/src/main/java/be/mygod/vpnhotspot/client/MacLookup.kt @@ -47,7 +47,7 @@ object MacLookup { try { val response = conn.inputStream.bufferedReader().readText() val obj = JSONObject(response).getJSONObject("result") - obj.optString("error", null)?.also { throw UnexpectedError(mac, it) } + obj.opt("error")?.also { throw UnexpectedError(mac, it.toString()) } val company = obj.getString("company") val match = extractCountry(mac, response, obj) val result = if (match != null) { @@ -71,9 +71,9 @@ object MacLookup { } private fun extractCountry(mac: Long, response: String, obj: JSONObject): MatchResult? { - obj.optString("country")?.let { countryCodeRegex.matchEntire(it) }?.also { return it } + countryCodeRegex.matchEntire(obj.optString("country"))?.also { return it } val address = obj.optString("address") - if (address.isNullOrBlank()) return null + if (address.isBlank()) return null countryCodeRegex.find(address)?.also { return it } Timber.w(UnexpectedError(mac, response)) return null diff --git a/mobile/src/main/java/be/mygod/vpnhotspot/manage/LocalOnlyHotspotManager.kt b/mobile/src/main/java/be/mygod/vpnhotspot/manage/LocalOnlyHotspotManager.kt index c57d2769..db30b982 100644 --- a/mobile/src/main/java/be/mygod/vpnhotspot/manage/LocalOnlyHotspotManager.kt +++ b/mobile/src/main/java/be/mygod/vpnhotspot/manage/LocalOnlyHotspotManager.kt @@ -7,14 +7,10 @@ import android.content.ComponentName import android.content.Intent import android.content.ServiceConnection import android.content.pm.PackageManager -import android.graphics.Typeface import android.location.LocationManager import android.os.Build import android.os.IBinder import android.provider.Settings -import android.text.SpannableStringBuilder -import android.text.Spanned -import android.text.style.TypefaceSpan import android.view.View import android.widget.Toast import androidx.core.content.getSystemService @@ -31,6 +27,11 @@ import java.net.NetworkInterface @TargetApi(26) class LocalOnlyHotspotManager(private val parent: TetheringFragment) : Manager(), ServiceConnection { + companion object { + val permission = if (Build.VERSION.SDK_INT >= 29) + Manifest.permission.ACCESS_FINE_LOCATION else Manifest.permission.ACCESS_COARSE_LOCATION + } + class ViewHolder(val binding: ListitemInterfaceBinding) : RecyclerView.ViewHolder(binding.root), View.OnClickListener { init { @@ -43,10 +44,8 @@ class LocalOnlyHotspotManager(private val parent: TetheringFragment) : Manager() val binder = manager.binder if (binder?.iface != null) binder.stop() else { val context = manager.parent.requireContext() - if (context.checkSelfPermission(Manifest.permission.ACCESS_COARSE_LOCATION) != - PackageManager.PERMISSION_GRANTED) { - manager.parent.requestPermissions(arrayOf(Manifest.permission.ACCESS_COARSE_LOCATION), - TetheringFragment.START_LOCAL_ONLY_HOTSPOT) + if (context.checkSelfPermission(permission) != PackageManager.PERMISSION_GRANTED) { + manager.parent.requestPermissions(arrayOf(permission), TetheringFragment.START_LOCAL_ONLY_HOTSPOT) return } /** @@ -75,15 +74,7 @@ class LocalOnlyHotspotManager(private val parent: TetheringFragment) : Manager() private val lookup: Map get() = parent.ifaceLookup override val icon get() = R.drawable.ic_action_perm_scan_wifi - override val title: CharSequence get() { - val configuration = binder?.configuration ?: return parent.getString(R.string.tethering_temp_hotspot) - return SpannableStringBuilder("${configuration.SSID} - ").apply { - val start = length - append(configuration.preSharedKey) - setSpan(if (Build.VERSION.SDK_INT >= 28) TypefaceSpan(Typeface.MONOSPACE) else - TypefaceSpan("monospace"), start, length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) - } - } + override val title: CharSequence get() = parent.getString(R.string.tethering_temp_hotspot) override val text: CharSequence get() { return lookup[binder?.iface ?: return ""]?.formatAddresses() ?: "" } @@ -98,7 +89,7 @@ class LocalOnlyHotspotManager(private val parent: TetheringFragment) : Manager() override val type get() = VIEW_TYPE_LOCAL_ONLY_HOTSPOT private val data = Data() - private var binder: LocalOnlyHotspotService.Binder? = null + internal var binder: LocalOnlyHotspotService.Binder? = null override fun bindTo(viewHolder: RecyclerView.ViewHolder) { viewHolder as ViewHolder diff --git a/mobile/src/main/java/be/mygod/vpnhotspot/manage/RepeaterManager.kt b/mobile/src/main/java/be/mygod/vpnhotspot/manage/RepeaterManager.kt index 00050db7..24121e71 100644 --- a/mobile/src/main/java/be/mygod/vpnhotspot/manage/RepeaterManager.kt +++ b/mobile/src/main/java/be/mygod/vpnhotspot/manage/RepeaterManager.kt @@ -1,11 +1,15 @@ package be.mygod.vpnhotspot.manage +import android.Manifest import android.content.ComponentName import android.content.DialogInterface import android.content.Intent import android.content.ServiceConnection +import android.content.pm.PackageManager import android.net.wifi.WifiConfiguration +import android.net.wifi.p2p.WifiP2pConfig import android.net.wifi.p2p.WifiP2pGroup +import android.os.Build import android.os.Bundle import android.os.IBinder import android.os.Parcelable @@ -21,10 +25,8 @@ import androidx.lifecycle.ViewModelProviders import androidx.lifecycle.get import androidx.recyclerview.widget.RecyclerView import be.mygod.vpnhotspot.* -import be.mygod.vpnhotspot.App.Companion.app import be.mygod.vpnhotspot.databinding.ListitemRepeaterBinding -import be.mygod.vpnhotspot.net.wifi.P2pSupplicantConfiguration -import be.mygod.vpnhotspot.net.wifi.WifiP2pDialogFragment +import be.mygod.vpnhotspot.net.wifi.configuration.* import be.mygod.vpnhotspot.util.ServiceForegroundConnector import be.mygod.vpnhotspot.util.formatAddresses import be.mygod.vpnhotspot.widget.SmartSnackbar @@ -51,7 +53,12 @@ class RepeaterManager(private val parent: TetheringFragment) : Manager(), Servic else -> false } - val ssid @Bindable get() = binder?.group?.networkName ?: "" + val title: CharSequence @Bindable get() { + if (Build.VERSION.SDK_INT >= 29) binder?.group?.frequency?.let { + return parent.getString(R.string.repeater_channel, it, frequencyToChannel(it)) + } + return parent.getString(R.string.title_repeater) + } val addresses: CharSequence @Bindable get() { return try { NetworkInterface.getByName(p2pInterface ?: return "")?.formatAddresses() ?: "" @@ -59,12 +66,6 @@ class RepeaterManager(private val parent: TetheringFragment) : Manager(), Servic "" } } - var oc: CharSequence - @Bindable get() { - val oc = RepeaterService.operatingChannel - return if (oc in 1..165) oc.toString() else "" - } - set(value) = app.pref.edit().putString(RepeaterService.KEY_OPERATING_CHANNEL, value.toString()).apply() fun onStatusChanged() { notifyPropertyChanged(BR.switchEnabled) @@ -72,8 +73,8 @@ class RepeaterManager(private val parent: TetheringFragment) : Manager(), Servic notifyPropertyChanged(BR.addresses) } fun onGroupChanged(group: WifiP2pGroup? = null) { - notifyPropertyChanged(BR.ssid) p2pInterface = group?.`interface` + if (Build.VERSION.SDK_INT >= 29) notifyPropertyChanged(BR.title) notifyPropertyChanged(BR.addresses) } @@ -82,6 +83,12 @@ class RepeaterManager(private val parent: TetheringFragment) : Manager(), Servic when (binder?.service?.status) { RepeaterService.Status.IDLE -> { val context = parent.requireContext() + if (Build.VERSION.SDK_INT >= 29 && context.checkSelfPermission( + Manifest.permission.ACCESS_FINE_LOCATION) != PackageManager.PERMISSION_GRANTED) { + parent.requestPermissions(arrayOf(Manifest.permission.ACCESS_FINE_LOCATION), + TetheringFragment.START_REPEATER) + return + } ContextCompat.startForegroundService(context, Intent(context, RepeaterService::class.java)) } RepeaterService.Status.ACTIVE -> binder.shutdown() @@ -92,22 +99,6 @@ class RepeaterManager(private val parent: TetheringFragment) : Manager(), Servic fun wps() { if (binder?.active == true) WpsDialogFragment().show(parent, TetheringFragment.REPEATER_WPS) } - - fun editConfigurations() { - val group = binder?.group - if (group != null) try { - val config = P2pSupplicantConfiguration(group, binder?.thisDevice?.deviceAddress) - holder.config = config - WifiP2pDialogFragment().withArg(WifiP2pDialogFragment.Arg(WifiConfiguration().apply { - SSID = group.networkName - preSharedKey = config.psk - })).show(parent, TetheringFragment.REPEATER_EDIT_CONFIGURATION) - return - } catch (e: RuntimeException) { - Timber.w(e) - } - SmartSnackbar.make(R.string.repeater_configure_failure).show() - } } @Parcelize @@ -128,6 +119,8 @@ class RepeaterManager(private val parent: TetheringFragment) : Manager(), Servic } } + @Deprecated("No longer used since API 29") + @Suppress("DEPRECATION") class ConfigHolder : ViewModel() { var config: P2pSupplicantConfiguration? = null } @@ -140,6 +133,7 @@ class RepeaterManager(private val parent: TetheringFragment) : Manager(), Servic private val data = Data() internal var binder: RepeaterService.Binder? = null private var p2pInterface: String? = null + @Suppress("DEPRECATION") private val holder = ViewModelProviders.of(parent).get() override fun bindTo(viewHolder: RecyclerView.ViewHolder) { @@ -168,19 +162,61 @@ class RepeaterManager(private val parent: TetheringFragment) : Manager(), Servic } } - fun onEditResult(which: Int, data: Intent?) { - when (which) { - DialogInterface.BUTTON_POSITIVE -> try { - val master = holder.config ?: return - val config = AlertDialogFragment.getRet(data!!).configuration + val configuration: WifiConfiguration? get() { + if (Build.VERSION.SDK_INT >= 29) { + val networkName = RepeaterService.networkName + val passphrase = RepeaterService.passphrase + if (networkName != null && passphrase != null) { + return newWifiApConfiguration(networkName, passphrase).apply { + allowedKeyManagement.set(WifiConfiguration.KeyMgmt.WPA_PSK) // is not actually used + apBand = when (RepeaterService.operatingBand) { + WifiP2pConfig.GROUP_OWNER_BAND_AUTO -> AP_BAND_ANY + WifiP2pConfig.GROUP_OWNER_BAND_2GHZ -> AP_BAND_2GHZ + WifiP2pConfig.GROUP_OWNER_BAND_5GHZ -> AP_BAND_5GHZ + else -> throw IllegalArgumentException("Unknown operatingBand") + } + apChannel = RepeaterService.operatingChannel + } + } + } else @Suppress("DEPRECATION") { + val group = binder?.group + if (group != null) try { + val config = P2pSupplicantConfiguration(group, binder?.thisDevice?.deviceAddress) + holder.config = config + return newWifiApConfiguration(group.networkName, config.psk).apply { + allowedKeyManagement.set(WifiConfiguration.KeyMgmt.WPA_PSK) // is not actually used + if (Build.VERSION.SDK_INT >= 23) { + apBand = AP_BAND_ANY + apChannel = RepeaterService.operatingChannel + } + } + } catch (e: RuntimeException) { + Timber.w(e) + } + } + SmartSnackbar.make(R.string.repeater_configure_failure).show() + return null + } + fun updateConfiguration(config: WifiConfiguration) { + if (Build.VERSION.SDK_INT >= 29) { + RepeaterService.networkName = config.SSID + RepeaterService.passphrase = config.preSharedKey + RepeaterService.operatingBand = when (config.apBand) { + AP_BAND_ANY -> WifiP2pConfig.GROUP_OWNER_BAND_AUTO + AP_BAND_2GHZ -> WifiP2pConfig.GROUP_OWNER_BAND_2GHZ + AP_BAND_5GHZ -> WifiP2pConfig.GROUP_OWNER_BAND_5GHZ + else -> throw IllegalArgumentException("Unknown apBand") + } + } else @Suppress("DEPRECATION") holder.config?.let { master -> + if (binder?.group?.networkName != config.SSID || master.psk != config.preSharedKey) try { master.update(config.SSID, config.preSharedKey) binder!!.group = null } catch (e: Exception) { Timber.w(e) SmartSnackbar.make(e).show() } - DialogInterface.BUTTON_NEUTRAL -> binder!!.resetCredentials() + holder.config = null } - holder.config = null + if (Build.VERSION.SDK_INT >= 23) RepeaterService.operatingChannel = config.apChannel } } diff --git a/mobile/src/main/java/be/mygod/vpnhotspot/manage/TetherListeningTileService.kt b/mobile/src/main/java/be/mygod/vpnhotspot/manage/TetherListeningTileService.kt index 0917b7e0..49ffff08 100644 --- a/mobile/src/main/java/be/mygod/vpnhotspot/manage/TetherListeningTileService.kt +++ b/mobile/src/main/java/be/mygod/vpnhotspot/manage/TetherListeningTileService.kt @@ -9,17 +9,18 @@ import be.mygod.vpnhotspot.util.broadcastReceiver @RequiresApi(24) abstract class TetherListeningTileService : KillableTileService() { - protected var tethered: List = emptyList() + protected var tethered: List? = null private val receiver = broadcastReceiver { _, intent -> - tethered = intent.tetheredIfaces + tethered = intent.tetheredIfaces ?: return@broadcastReceiver updateTile() } override fun onStartListening() { super.onStartListening() - val intent = registerReceiver(receiver, IntentFilter(TetheringManager.ACTION_TETHER_STATE_CHANGED)) - if (intent != null) tethered = intent.tetheredIfaces + tethered = registerReceiver(receiver, IntentFilter(TetheringManager.ACTION_TETHER_STATE_CHANGED)) + ?.tetheredIfaces + updateTile() } override fun onStopListening() { diff --git a/mobile/src/main/java/be/mygod/vpnhotspot/manage/TetheringFragment.kt b/mobile/src/main/java/be/mygod/vpnhotspot/manage/TetheringFragment.kt index b6bfc2cc..c3bfd8da 100644 --- a/mobile/src/main/java/be/mygod/vpnhotspot/manage/TetheringFragment.kt +++ b/mobile/src/main/java/be/mygod/vpnhotspot/manage/TetheringFragment.kt @@ -1,15 +1,16 @@ package be.mygod.vpnhotspot.manage import android.annotation.TargetApi -import android.content.ComponentName -import android.content.Intent -import android.content.IntentFilter -import android.content.ServiceConnection +import android.content.* import android.content.pm.PackageManager import android.os.Build import android.os.Bundle import android.os.IBinder -import android.view.* +import android.view.LayoutInflater +import android.view.MenuItem +import android.view.View +import android.view.ViewGroup +import androidx.appcompat.widget.Toolbar import androidx.core.content.ContextCompat import androidx.databinding.DataBindingUtil import androidx.fragment.app.Fragment @@ -23,24 +24,31 @@ import be.mygod.vpnhotspot.net.TetherType import be.mygod.vpnhotspot.net.TetheringManager import be.mygod.vpnhotspot.net.TetheringManager.localOnlyTetheredIfaces import be.mygod.vpnhotspot.net.TetheringManager.tetheredIfaces +import be.mygod.vpnhotspot.net.wifi.WifiApManager +import be.mygod.vpnhotspot.net.wifi.configuration.WifiApDialogFragment import be.mygod.vpnhotspot.util.ServiceForegroundConnector import be.mygod.vpnhotspot.util.broadcastReceiver import be.mygod.vpnhotspot.util.isNotGone +import be.mygod.vpnhotspot.widget.SmartSnackbar import kotlinx.android.synthetic.main.activity_main.* import timber.log.Timber +import java.lang.IllegalArgumentException +import java.lang.reflect.InvocationTargetException import java.net.NetworkInterface import java.net.SocketException -class TetheringFragment : Fragment(), ServiceConnection, MenuItem.OnMenuItemClickListener { +class TetheringFragment : Fragment(), ServiceConnection, Toolbar.OnMenuItemClickListener { companion object { + const val START_REPEATER = 4 const val START_LOCAL_ONLY_HOTSPOT = 1 - const val REPEATER_EDIT_CONFIGURATION = 2 const val REPEATER_WPS = 3 + const val CONFIGURE_REPEATER = 2 + const val CONFIGURE_AP = 4 } inner class ManagerAdapter : ListAdapter(Manager) { internal val repeaterManager by lazy { RepeaterManager(this@TetheringFragment) } - private val localOnlyHotspotManager by lazy @TargetApi(26) { LocalOnlyHotspotManager(this@TetheringFragment) } + internal val localOnlyHotspotManager by lazy @TargetApi(26) { LocalOnlyHotspotManager(this@TetheringFragment) } private val tetherManagers by lazy @TargetApi(24) { listOf(TetherManager.Wifi(this@TetheringFragment), TetherManager.Usb(this@TetheringFragment), @@ -91,8 +99,9 @@ class TetheringFragment : Fragment(), ServiceConnection, MenuItem.OnMenuItemClic var binder: TetheringService.Binder? = null private val adapter = ManagerAdapter() private val receiver = broadcastReceiver { _, intent -> - adapter.update(intent.tetheredIfaces, intent.localOnlyTetheredIfaces, - intent.getStringArrayListExtra(TetheringManager.EXTRA_ERRORED_TETHER)) + adapter.update(intent.tetheredIfaces ?: return@broadcastReceiver, + intent.localOnlyTetheredIfaces ?: return@broadcastReceiver, + intent.getStringArrayListExtra(TetheringManager.EXTRA_ERRORED_TETHER) ?: return@broadcastReceiver) } private fun updateMonitorList(canMonitor: List = emptyList()) { @@ -100,13 +109,47 @@ class TetheringFragment : Fragment(), ServiceConnection, MenuItem.OnMenuItemClic item.isNotGone = canMonitor.isNotEmpty() item.subMenu.apply { clear() - canMonitor.sorted().forEach { add(it).setOnMenuItemClickListener(this@TetheringFragment) } + for (iface in canMonitor.sorted()) add(iface).setOnMenuItemClickListener { + ContextCompat.startForegroundService(requireContext(), Intent(context, TetheringService::class.java) + .putExtra(TetheringService.EXTRA_ADD_INTERFACE_MONITOR, iface)) + true + } } } override fun onMenuItemClick(item: MenuItem?): Boolean { - ContextCompat.startForegroundService(requireContext(), Intent(context, TetheringService::class.java) - .putExtra(TetheringService.EXTRA_ADD_INTERFACE_MONITOR, item?.title ?: return false)) - return true + return when (item?.itemId) { + R.id.configuration -> item.subMenu.run { + findItem(R.id.configuration_repeater).isNotGone = RepeaterService.supported + findItem(R.id.configuration_temp_hotspot).isNotGone = + adapter.localOnlyHotspotManager.binder?.configuration != null + true + } + R.id.configuration_repeater -> { + WifiApDialogFragment().withArg(WifiApDialogFragment.Arg( + adapter.repeaterManager.configuration ?: return false, + p2pMode = true + )).show(this, CONFIGURE_REPEATER) + true + } + R.id.configuration_temp_hotspot -> { + WifiApDialogFragment().withArg(WifiApDialogFragment.Arg( + adapter.localOnlyHotspotManager.binder?.configuration ?: return false, + readOnly = true + )).show(this, 0) // read-only, no callback needed + true + } + R.id.configuration_ap -> try { + WifiApDialogFragment().withArg(WifiApDialogFragment.Arg( + WifiApManager.configuration + )).show(this, CONFIGURE_AP) + true + } catch (e: InvocationTargetException) { + if (e.targetException !is SecurityException) Timber.w(e) + SmartSnackbar.make(e.targetException).show() + false + } + else -> false + } } override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { @@ -117,13 +160,19 @@ class TetheringFragment : Fragment(), ServiceConnection, MenuItem.OnMenuItemClic binding.interfaces.adapter = adapter adapter.update(emptyList(), emptyList(), emptyList()) ServiceForegroundConnector(this, this, TetheringService::class) - requireActivity().toolbar.inflateMenu(R.menu.toolbar_tethering) + requireActivity().toolbar.apply { + inflateMenu(R.menu.toolbar_tethering) + setOnMenuItemClickListener(this@TetheringFragment) + } return binding.root } override fun onDestroyView() { super.onDestroyView() - requireActivity().toolbar.menu.clear() + requireActivity().toolbar.apply { + menu.clear() + setOnMenuItemClickListener(null) + } } override fun onResume() { @@ -131,19 +180,38 @@ class TetheringFragment : Fragment(), ServiceConnection, MenuItem.OnMenuItemClic if (Build.VERSION.SDK_INT >= 27) ManageBar.Data.notifyChange() } - override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) = when (requestCode) { - REPEATER_WPS -> adapter.repeaterManager.onWpsResult(resultCode, data) - REPEATER_EDIT_CONFIGURATION -> adapter.repeaterManager.onEditResult(resultCode, data) - else -> super.onActivityResult(requestCode, resultCode, data) + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) { + val configuration by lazy { AlertDialogFragment.getRet(data!!).configuration } + when (requestCode) { + REPEATER_WPS -> adapter.repeaterManager.onWpsResult(resultCode, data) + CONFIGURE_REPEATER -> if (resultCode == DialogInterface.BUTTON_POSITIVE) { + adapter.repeaterManager.updateConfiguration(configuration) + } + CONFIGURE_AP -> if (resultCode == DialogInterface.BUTTON_POSITIVE) try { + WifiApManager.configuration = configuration + } catch (e: IllegalArgumentException) { + SmartSnackbar.make(R.string.configuration_rejected).show() + } catch (e: InvocationTargetException) { + SmartSnackbar.make(e.targetException).show() + } + else -> super.onActivityResult(requestCode, resultCode, data) + } } override fun onRequestPermissionsResult(requestCode: Int, permissions: Array, grantResults: IntArray) { - if (requestCode == START_LOCAL_ONLY_HOTSPOT) @TargetApi(26) { - if (grantResults.firstOrNull() == PackageManager.PERMISSION_GRANTED) { + when (requestCode) { + START_REPEATER -> if (grantResults.firstOrNull() == PackageManager.PERMISSION_GRANTED) @TargetApi(29) { val context = requireContext() - context.startForegroundService(Intent(context, LocalOnlyHotspotService::class.java)) + context.startForegroundService(Intent(context, RepeaterService::class.java)) } - } else super.onRequestPermissionsResult(requestCode, permissions, grantResults) + START_LOCAL_ONLY_HOTSPOT -> { + if (grantResults.firstOrNull() == PackageManager.PERMISSION_GRANTED) @TargetApi(26) { + val context = requireContext() + context.startForegroundService(Intent(context, LocalOnlyHotspotService::class.java)) + } + } + else -> super.onRequestPermissionsResult(requestCode, permissions, grantResults) + } } override fun onServiceConnected(name: ComponentName?, service: IBinder?) { diff --git a/mobile/src/main/java/be/mygod/vpnhotspot/manage/TetheringTileService.kt b/mobile/src/main/java/be/mygod/vpnhotspot/manage/TetheringTileService.kt index 143cea53..4fa87258 100644 --- a/mobile/src/main/java/be/mygod/vpnhotspot/manage/TetheringTileService.kt +++ b/mobile/src/main/java/be/mygod/vpnhotspot/manage/TetheringTileService.kt @@ -29,7 +29,7 @@ sealed class TetheringTileService : TetherListeningTileService(), TetheringManag protected abstract val labelString: Int protected abstract val tetherType: TetherType protected open val icon get() = tetherType.icon - protected val interested get() = tethered.filter { TetherType.ofInterface(it) == tetherType } + protected val interested get() = tethered?.filter { TetherType.ofInterface(it) == tetherType } protected var binder: TetheringService.Binder? = null protected abstract fun start() @@ -52,19 +52,27 @@ sealed class TetheringTileService : TetherListeningTileService(), TetheringManag } override fun onServiceDisconnected(name: ComponentName?) { + binder?.routingsChanged?.remove(this) binder = null } override fun updateTile() { qsTile?.run { val interested = interested - if (interested.isEmpty()) { - state = Tile.STATE_INACTIVE - icon = tileOff - } else { - val binder = binder ?: return - state = Tile.STATE_ACTIVE - icon = if (interested.all(binder::isActive)) tileOn else tileOff + when { + interested == null -> { + state = Tile.STATE_UNAVAILABLE + icon = tileOff + } + interested.isEmpty() -> { + state = Tile.STATE_INACTIVE + icon = tileOff + } + else -> { + val binder = binder ?: return + state = Tile.STATE_ACTIVE + icon = if (interested.all(binder::isActive)) tileOn else tileOff + } } label = getText(labelString) updateTile() @@ -88,7 +96,7 @@ sealed class TetheringTileService : TetherListeningTileService(), TetheringManag } } override fun onClick() { - val interested = interested + val interested = interested ?: return if (interested.isEmpty()) safeInvoker { start() } else { val binder = binder if (binder == null) tapPending = true else { @@ -146,11 +154,14 @@ sealed class TetheringTileService : TetherListeningTileService(), TetheringManag override fun updateTile() { qsTile?.run { - when (tethering?.active) { + val interested = interested + if (interested == null) { + state = Tile.STATE_UNAVAILABLE + icon = tileOff + } else when (tethering?.active) { true -> { val binder = binder ?: return state = Tile.STATE_ACTIVE - val interested = interested icon = if (interested.isNotEmpty() && interested.all(binder::isActive)) tileOn else tileOff } false -> { @@ -164,18 +175,20 @@ sealed class TetheringTileService : TetherListeningTileService(), TetheringManag } } - override fun onClick() = when (tethering?.active) { - true -> { - val binder = binder - if (binder == null) tapPending = true else { - val inactive = interested.filterNot(binder::isActive) - if (inactive.isEmpty()) safeInvoker { stop() } - else ContextCompat.startForegroundService(this, Intent(this, TetheringService::class.java) - .putExtra(TetheringService.EXTRA_ADD_INTERFACES, inactive.toTypedArray())) + override fun onClick() { + when (tethering?.active) { + true -> { + val binder = binder + if (binder == null) tapPending = true else { + val inactive = (interested ?: return).filterNot(binder::isActive) + if (inactive.isEmpty()) safeInvoker { stop() } + else ContextCompat.startForegroundService(this, Intent(this, TetheringService::class.java) + .putExtra(TetheringService.EXTRA_ADD_INTERFACES, inactive.toTypedArray())) + } } + false -> safeInvoker { start() } + else -> tapPending = true } - false -> safeInvoker { start() } - else -> tapPending = true } } diff --git a/mobile/src/main/java/be/mygod/vpnhotspot/net/IpNeighbour.kt b/mobile/src/main/java/be/mygod/vpnhotspot/net/IpNeighbour.kt index 4a4a88ed..603aeecd 100644 --- a/mobile/src/main/java/be/mygod/vpnhotspot/net/IpNeighbour.kt +++ b/mobile/src/main/java/be/mygod/vpnhotspot/net/IpNeighbour.kt @@ -1,15 +1,14 @@ package be.mygod.vpnhotspot.net +import android.os.Build import android.system.ErrnoException import android.system.OsConstants -import androidx.core.os.BuildCompat import be.mygod.vpnhotspot.room.macToLong import be.mygod.vpnhotspot.util.parseNumericAddress import timber.log.Timber import java.io.File import java.io.FileNotFoundException import java.io.IOException -import java.lang.NumberFormatException import java.net.InetAddress import java.net.NetworkInterface import java.net.SocketException @@ -98,7 +97,7 @@ data class IpNeighbour(val ip: InetAddress, val dev: String, val lladdr: Long, v .filter { it.size >= 6 && mac.matcher(it[ARP_HW_ADDRESS]).matches() } .toList() } catch (e: IOException) { - if (e !is FileNotFoundException || !BuildCompat.isAtLeastQ() || + if (e !is FileNotFoundException || Build.VERSION.SDK_INT < 29 || (e.cause as? ErrnoException)?.errno != OsConstants.EACCES) Timber.w(e) } return arpCache diff --git a/mobile/src/main/java/be/mygod/vpnhotspot/net/Routing.kt b/mobile/src/main/java/be/mygod/vpnhotspot/net/Routing.kt index 51ccf72c..93460053 100644 --- a/mobile/src/main/java/be/mygod/vpnhotspot/net/Routing.kt +++ b/mobile/src/main/java/be/mygod/vpnhotspot/net/Routing.kt @@ -11,6 +11,7 @@ import be.mygod.vpnhotspot.room.AppDatabase import be.mygod.vpnhotspot.util.RootSession import be.mygod.vpnhotspot.util.computeIfAbsentCompat import be.mygod.vpnhotspot.widget.SmartSnackbar +import com.crashlytics.android.Crashlytics import kotlinx.coroutines.runBlocking import timber.log.Timber import java.io.IOException @@ -91,7 +92,12 @@ class Routing(private val caller: Any, private val downstream: String) : IpNeigh } private val hostAddress = try { - NetworkInterface.getByName(downstream)!!.interfaceAddresses!!.asSequence().single { it.address is Inet4Address } + val addresses = NetworkInterface.getByName(downstream)!!.interfaceAddresses!! + .filter { it.address is Inet4Address } + if (addresses.size > 1) { + Crashlytics.logException(IllegalArgumentException("More than one addresses was found: $addresses")) + } + addresses.first() } catch (e: Exception) { throw InterfaceNotFoundException(e) } @@ -200,9 +206,9 @@ class Routing(private val caller: Any, private val downstream: String) : IpNeigh override fun onIpNeighbourAvailable(neighbours: List) = synchronized(this) { val toRemove = HashSet(clients.keys) for (neighbour in neighbours) { - if (neighbour.dev != downstream || neighbour.ip !is Inet4Address || - runBlocking { AppDatabase.instance.clientRecordDao.lookup(neighbour.lladdr) } - ?.blocked == true) continue + if (neighbour.dev != downstream || neighbour.ip !is Inet4Address || runBlocking { + AppDatabase.instance.clientRecordDao.lookupOrDefault(neighbour.lladdr) + }.blocked) continue toRemove.remove(neighbour.ip) try { clients.computeIfAbsentCompat(neighbour.ip) { Client(neighbour.ip, neighbour.lladdr) } diff --git a/mobile/src/main/java/be/mygod/vpnhotspot/net/wifi/WifiApManager.kt b/mobile/src/main/java/be/mygod/vpnhotspot/net/wifi/WifiApManager.kt index 653d3f78..ca81b80c 100644 --- a/mobile/src/main/java/be/mygod/vpnhotspot/net/wifi/WifiApManager.kt +++ b/mobile/src/main/java/be/mygod/vpnhotspot/net/wifi/WifiApManager.kt @@ -3,15 +3,25 @@ package be.mygod.vpnhotspot.net.wifi import android.net.wifi.WifiConfiguration import android.net.wifi.WifiManager import be.mygod.vpnhotspot.App.Companion.app +import java.lang.IllegalArgumentException -/** - * Although the functionalities were removed in API 26, it is already not functioning correctly on API 25. - * - * See also: https://android.googlesource.com/platform/frameworks/base/+/5c0b10a4a9eecc5307bb89a271221f2b20448797%5E%21/ - */ object WifiApManager { - private val setWifiApEnabled = WifiManager::class.java.getDeclaredMethod("setWifiApEnabled", - WifiConfiguration::class.java, Boolean::class.java) + private val getWifiApConfiguration by lazy { WifiManager::class.java.getDeclaredMethod("getWifiApConfiguration") } + private val setWifiApConfiguration by lazy { + WifiManager::class.java.getDeclaredMethod("setWifiApConfiguration", WifiConfiguration::class.java) + } + var configuration: WifiConfiguration + get() = getWifiApConfiguration.invoke(app.wifi) as WifiConfiguration + set(value) { + if (setWifiApConfiguration.invoke(app.wifi, value) as? Boolean != true) { + throw IllegalArgumentException("setWifiApConfiguration failed") + } + } + + private val setWifiApEnabled by lazy { + WifiManager::class.java.getDeclaredMethod("setWifiApEnabled", + WifiConfiguration::class.java, Boolean::class.java) + } /** * Start AccessPoint mode with the specified * configuration. If the radio is already running in @@ -25,11 +35,18 @@ object WifiApManager { private fun WifiManager.setWifiApEnabled(wifiConfig: WifiConfiguration?, enabled: Boolean) = setWifiApEnabled.invoke(this, wifiConfig, enabled) as Boolean + /** + * Although the functionalities were removed in API 26, it is already not functioning correctly on API 25. + * + * See also: https://android.googlesource.com/platform/frameworks/base/+/5c0b10a4a9eecc5307bb89a271221f2b20448797%5E%21/ + */ + @Suppress("DEPRECATION") @Deprecated("Not usable since API 26, malfunctioning on API 25") fun start(wifiConfig: WifiConfiguration? = null) { app.wifi.isWifiEnabled = false app.wifi.setWifiApEnabled(wifiConfig, true) } + @Suppress("DEPRECATION") @Deprecated("Not usable since API 26") fun stop() { app.wifi.setWifiApEnabled(null, false) diff --git a/mobile/src/main/java/be/mygod/vpnhotspot/net/wifi/WifiDoubleLock.kt b/mobile/src/main/java/be/mygod/vpnhotspot/net/wifi/WifiDoubleLock.kt index 45261e1b..4ce1be0c 100644 --- a/mobile/src/main/java/be/mygod/vpnhotspot/net/wifi/WifiDoubleLock.kt +++ b/mobile/src/main/java/be/mygod/vpnhotspot/net/wifi/WifiDoubleLock.kt @@ -3,8 +3,15 @@ package be.mygod.vpnhotspot.net.wifi import android.annotation.SuppressLint import android.content.SharedPreferences import android.net.wifi.WifiManager +import android.os.Build import android.os.PowerManager +import android.view.WindowManager +import androidx.activity.ComponentActivity +import androidx.annotation.RequiresApi +import androidx.core.content.edit import androidx.core.content.getSystemService +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.LifecycleOwner import be.mygod.vpnhotspot.App.Companion.app /** @@ -13,8 +20,12 @@ import be.mygod.vpnhotspot.App.Companion.app class WifiDoubleLock(lockType: Int) : AutoCloseable { companion object : SharedPreferences.OnSharedPreferenceChangeListener { private const val KEY = "service.wifiLock" - private val lockType get() = - WifiDoubleLock.Mode.valueOf(app.pref.getString(KEY, WifiDoubleLock.Mode.Full.toString()) ?: "").lockType + var mode: Mode + @Suppress("DEPRECATION") + get() = Mode.valueOf(app.pref.getString(KEY, Mode.Full.toString()) ?: "").let { + if (it == Mode.Full && Build.VERSION.SDK_INT >= 29) Mode.None else it + } + set(value) = app.pref.edit { putString(KEY, value.toString()) } private val service by lazy { app.getSystemService()!! } private var holders = mutableSetOf() @@ -23,7 +34,7 @@ class WifiDoubleLock(lockType: Int) : AutoCloseable { fun acquire(holder: Any) = synchronized(this) { if (holders.isEmpty()) { app.pref.registerOnSharedPreferenceChangeListener(this) - val lockType = lockType + val lockType = mode.lockType if (lockType != null) lock = WifiDoubleLock(lockType) } check(holders.add(holder)) @@ -40,14 +51,44 @@ class WifiDoubleLock(lockType: Int) : AutoCloseable { override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences?, key: String?) { if (key == KEY) synchronized(this) { lock?.close() - val lockType = lockType + val lockType = mode.lockType lock = if (lockType == null) null else WifiDoubleLock(lockType) } } } - enum class Mode(val lockType: Int? = null) { - None, Full(WifiManager.WIFI_MODE_FULL), HighPerf(WifiManager.WIFI_MODE_FULL_HIGH_PERF) + enum class Mode(val lockType: Int? = null, val keepScreenOn: Boolean = false) { + None, + @Suppress("DEPRECATION") + @Deprecated("This constant was deprecated in API level Q.\n" + + "This API is non-functional and will have no impact.") + Full(WifiManager.WIFI_MODE_FULL), + HighPerf(WifiManager.WIFI_MODE_FULL_HIGH_PERF), + @RequiresApi(29) + LowLatency(WifiManager.WIFI_MODE_FULL_LOW_LATENCY, true), + } + + class ActivityListener(private val activity: ComponentActivity) : + DefaultLifecycleObserver, SharedPreferences.OnSharedPreferenceChangeListener { + private var keepScreenOn: Boolean = false + set(value) { + if (field == value) return + field = value + if (value) activity.window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + else activity.window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + } + + init { + activity.lifecycle.addObserver(this) + app.pref.registerOnSharedPreferenceChangeListener(this) + keepScreenOn = mode.keepScreenOn + } + + override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences?, key: String?) { + if (key == KEY) keepScreenOn = mode.keepScreenOn + } + + override fun onDestroy(owner: LifecycleOwner) = app.pref.unregisterOnSharedPreferenceChangeListener(this) } private val wifi = app.wifi.createWifiLock(lockType, "vpnhotspot:wifi").apply { acquire() } diff --git a/mobile/src/main/java/be/mygod/vpnhotspot/net/wifi/WifiP2pDialogFragment.kt b/mobile/src/main/java/be/mygod/vpnhotspot/net/wifi/WifiP2pDialogFragment.kt deleted file mode 100644 index f430baf3..00000000 --- a/mobile/src/main/java/be/mygod/vpnhotspot/net/wifi/WifiP2pDialogFragment.kt +++ /dev/null @@ -1,75 +0,0 @@ -package be.mygod.vpnhotspot.net.wifi - -import android.content.DialogInterface -import android.net.wifi.WifiConfiguration -import android.net.wifi.WifiConfiguration.AuthAlgorithm -import android.os.Parcelable -import android.text.Editable -import android.text.TextWatcher -import android.view.View -import android.widget.EditText -import android.widget.TextView -import androidx.appcompat.app.AlertDialog -import be.mygod.vpnhotspot.AlertDialogFragment -import be.mygod.vpnhotspot.R -import kotlinx.android.parcel.Parcelize -import kotlinx.android.synthetic.main.dialog_wifi_ap.view.* -import java.nio.charset.Charset - -/** - * Based on: https://android.googlesource.com/platform/packages/apps/Settings/+/39b4674/src/com/android/settings/wifi/WifiApDialog.java - * - * This dialog has been deprecated in API 28, but we are still using it since it works better for our purposes. - * Related: https://android.googlesource.com/platform/packages/apps/Settings/+/defb1183ecb00d6231bac7d934d07f58f90261ea - */ -class WifiP2pDialogFragment : AlertDialogFragment(), TextWatcher { - @Parcelize - data class Arg(val configuration: WifiConfiguration) : Parcelable - - private lateinit var mView: View - private lateinit var mSsid: TextView - private lateinit var mPassword: EditText - override val ret: Arg? get() { - val config = WifiConfiguration() - config.SSID = mSsid.text.toString() - config.allowedAuthAlgorithms.set(AuthAlgorithm.OPEN) - if (mPassword.length() != 0) { - val password = mPassword.text.toString() - config.preSharedKey = password - } - return Arg(config) - } - - override fun AlertDialog.Builder.prepare(listener: DialogInterface.OnClickListener) { - mView = requireActivity().layoutInflater.inflate(R.layout.dialog_wifi_ap, null) - setView(mView) - setTitle(R.string.repeater_configure) - mSsid = mView.ssid - mPassword = mView.password - setPositiveButton(context.getString(R.string.wifi_save), listener) - setNegativeButton(context.getString(R.string.wifi_cancel), null) - setNeutralButton(context.getString(R.string.repeater_reset_credentials), listener) - mSsid.text = arg.configuration.SSID - mSsid.addTextChangedListener(this@WifiP2pDialogFragment) - mPassword.setText(arg.configuration.preSharedKey) - mPassword.addTextChangedListener(this@WifiP2pDialogFragment) - } - - override fun onStart() { - super.onStart() - validate() - } - - private fun validate() { - val mSsidString = mSsid.text.toString() - val ssidValid = mSsid.length() != 0 && Charset.forName("UTF-8").encode(mSsidString).limit() <= 32 - val passwordValid = mPassword.length() >= 8 - mView.password_wrapper.error = - if (passwordValid) null else requireContext().getString(R.string.credentials_password_too_short) - (dialog as AlertDialog).getButton(DialogInterface.BUTTON_POSITIVE).isEnabled = ssidValid && passwordValid - } - - override fun onTextChanged(s: CharSequence, start: Int, before: Int, count: Int) { } - override fun beforeTextChanged(s: CharSequence, start: Int, count: Int, after: Int) { } - override fun afterTextChanged(editable: Editable) = validate() -} diff --git a/mobile/src/main/java/be/mygod/vpnhotspot/net/wifi/WifiP2pManagerHelper.kt b/mobile/src/main/java/be/mygod/vpnhotspot/net/wifi/WifiP2pManagerHelper.kt index 794ec602..abb6a235 100644 --- a/mobile/src/main/java/be/mygod/vpnhotspot/net/wifi/WifiP2pManagerHelper.kt +++ b/mobile/src/main/java/be/mygod/vpnhotspot/net/wifi/WifiP2pManagerHelper.kt @@ -12,6 +12,7 @@ import java.lang.reflect.Proxy object WifiP2pManagerHelper { const val UNSUPPORTED = -2 + @Deprecated("No longer used since API 29") const val WIFI_P2P_PERSISTENT_GROUPS_CHANGED_ACTION = "android.net.wifi.p2p.PERSISTENT_GROUPS_CHANGED" /** @@ -24,6 +25,7 @@ object WifiP2pManagerHelper { WifiP2pManager::class.java.getDeclaredMethod("setWifiP2pChannels", WifiP2pManager.Channel::class.java, Int::class.java, Int::class.java, WifiP2pManager.ActionListener::class.java) } + @Deprecated("No longer used since API 29") fun WifiP2pManager.setWifiP2pChannels(c: WifiP2pManager.Channel, lc: Int, oc: Int, listener: WifiP2pManager.ActionListener) { try { @@ -39,18 +41,19 @@ object WifiP2pManagerHelper { * * Source: https://android.googlesource.com/platform/frameworks/base/+/android-4.3_r0.9/wifi/java/android/net/wifi/p2p/WifiP2pManager.java#958 */ - private val startWps by lazy { - WifiP2pManager::class.java.getDeclaredMethod("startWps", - WifiP2pManager.Channel::class.java, WpsInfo::class.java, WifiP2pManager.ActionListener::class.java) - } - fun WifiP2pManager.startWps(c: WifiP2pManager.Channel, wps: WpsInfo, listener: WifiP2pManager.ActionListener) { + @JvmStatic + val startWps by lazy { try { - startWps.invoke(this, c, wps, listener) + WifiP2pManager::class.java.getDeclaredMethod("startWps", + WifiP2pManager.Channel::class.java, WpsInfo::class.java, WifiP2pManager.ActionListener::class.java) } catch (e: NoSuchMethodException) { DebugHelper.logEvent("NoSuchMethod_startWps") - listener.onFailure(UNSUPPORTED) + null } } + fun WifiP2pManager.startWps(c: WifiP2pManager.Channel, wps: WpsInfo, listener: WifiP2pManager.ActionListener) { + startWps!!.invoke(this, c, wps, listener) + } /** * Available since Android 4.2. @@ -61,6 +64,7 @@ object WifiP2pManagerHelper { WifiP2pManager::class.java.getDeclaredMethod("deletePersistentGroup", WifiP2pManager.Channel::class.java, Int::class.java, WifiP2pManager.ActionListener::class.java) } + @Deprecated("No longer used since API 29") fun WifiP2pManager.deletePersistentGroup(c: WifiP2pManager.Channel, netId: Int, listener: WifiP2pManager.ActionListener) { try { @@ -87,6 +91,7 @@ object WifiP2pManagerHelper { * @param c is the channel created at {@link #initialize} * @param listener for callback when persistent group info list is available. Can be null. */ + @Deprecated("No longer used since API 29") fun WifiP2pManager.requestPersistentGroupInfo(c: WifiP2pManager.Channel, listener: (Collection) -> Unit) { val proxy = Proxy.newProxyInstance(interfacePersistentGroupInfoListener.classLoader, @@ -109,5 +114,6 @@ object WifiP2pManagerHelper { * Source: https://android.googlesource.com/platform/frameworks/base/+/android-4.2_r1/wifi/java/android/net/wifi/p2p/WifiP2pGroup.java#253 */ private val getNetworkId by lazy { WifiP2pGroup::class.java.getDeclaredMethod("getNetworkId") } + @Deprecated("No longer used since API 29") val WifiP2pGroup.netId get() = getNetworkId.invoke(this) as Int } diff --git a/mobile/src/main/java/be/mygod/vpnhotspot/net/wifi/P2pSupplicantConfiguration.kt b/mobile/src/main/java/be/mygod/vpnhotspot/net/wifi/configuration/P2pSupplicantConfiguration.kt similarity index 98% rename from mobile/src/main/java/be/mygod/vpnhotspot/net/wifi/P2pSupplicantConfiguration.kt rename to mobile/src/main/java/be/mygod/vpnhotspot/net/wifi/configuration/P2pSupplicantConfiguration.kt index 06d9b626..4e4279dc 100644 --- a/mobile/src/main/java/be/mygod/vpnhotspot/net/wifi/P2pSupplicantConfiguration.kt +++ b/mobile/src/main/java/be/mygod/vpnhotspot/net/wifi/configuration/P2pSupplicantConfiguration.kt @@ -1,4 +1,4 @@ -package be.mygod.vpnhotspot.net.wifi +package be.mygod.vpnhotspot.net.wifi.configuration import android.net.wifi.p2p.WifiP2pGroup import android.os.Build @@ -14,6 +14,7 @@ import java.lang.IllegalStateException * https://android.googlesource.com/platform/external/wpa_supplicant_8/+/d2986c2/wpa_supplicant/config.c#488 * https://android.googlesource.com/platform/external/wpa_supplicant_8/+/6fa46df/wpa_supplicant/config_file.c#182 */ +@Deprecated("No longer used since API 29") class P2pSupplicantConfiguration(private val group: WifiP2pGroup, ownerAddress: String?) { companion object { private const val TAG = "P2pSupplicantConfiguration" diff --git a/mobile/src/main/java/be/mygod/vpnhotspot/net/wifi/configuration/WifiApDialogFragment.kt b/mobile/src/main/java/be/mygod/vpnhotspot/net/wifi/configuration/WifiApDialogFragment.kt new file mode 100644 index 00000000..54eb2d60 --- /dev/null +++ b/mobile/src/main/java/be/mygod/vpnhotspot/net/wifi/configuration/WifiApDialogFragment.kt @@ -0,0 +1,214 @@ +package be.mygod.vpnhotspot.net.wifi.configuration + +import android.annotation.TargetApi +import android.content.ClipData +import android.content.DialogInterface +import android.net.wifi.WifiConfiguration +import android.os.Build +import android.os.Parcelable +import android.text.Editable +import android.text.TextWatcher +import android.util.Base64 +import android.view.MenuItem +import android.view.View +import android.widget.AdapterView +import android.widget.ArrayAdapter +import androidx.appcompat.app.AlertDialog +import androidx.appcompat.widget.Toolbar +import androidx.core.view.isGone +import be.mygod.vpnhotspot.AlertDialogFragment +import be.mygod.vpnhotspot.App.Companion.app +import be.mygod.vpnhotspot.R +import be.mygod.vpnhotspot.RepeaterService +import be.mygod.vpnhotspot.util.QRCodeDialog +import be.mygod.vpnhotspot.util.toByteArray +import be.mygod.vpnhotspot.util.toParcelable +import be.mygod.vpnhotspot.widget.SmartSnackbar +import kotlinx.android.parcel.Parcelize +import kotlinx.android.synthetic.main.dialog_wifi_ap.view.* +import java.nio.charset.Charset + +/** + * Based on: https://android.googlesource.com/platform/packages/apps/Settings/+/39b4674/src/com/android/settings/wifi/WifiApDialog.java + * + * This dialog has been deprecated in API 28, but we are still using it since it works better for our purposes. + * Related: https://android.googlesource.com/platform/packages/apps/Settings/+/defb1183ecb00d6231bac7d934d07f58f90261ea + */ +class WifiApDialogFragment : AlertDialogFragment(), TextWatcher, + Toolbar.OnMenuItemClickListener { + companion object { + private const val BASE64_FLAGS = Base64.NO_PADDING or Base64.NO_WRAP + private val channels by lazy { (1..165).map { BandOption.Channel(it) } } + } + + @Parcelize + data class Arg(val configuration: WifiConfiguration, + val readOnly: Boolean = false, + /** + * KeyMgmt is enforced to WPA_PSK. + * Various values for apBand are allowed according to different rules. + */ + val p2pMode: Boolean = false) : Parcelable + + @TargetApi(23) + private sealed class BandOption { + open val apBand get() = AP_BAND_2GHZ + open val apChannel get() = 0 + + object BandAny : BandOption() { + override val apBand get() = AP_BAND_ANY + override fun toString() = app.getString(R.string.wifi_ap_choose_auto) + } + object Band2GHz : BandOption() { + override fun toString() = app.getString(R.string.wifi_ap_choose_2G) + } + object Band5GHz : BandOption() { + override val apBand get() = AP_BAND_5GHZ + override fun toString() = app.getString(R.string.wifi_ap_choose_5G) + } + class Channel(override val apChannel: Int) : BandOption() { + override fun toString() = "${channelToFrequency(apChannel)} MHz ($apChannel)" + } + } + + private lateinit var dialogView: View + private lateinit var bandOptions: MutableList + private var started = false + private val selectedSecurity get() = + if (arg.p2pMode) WifiConfiguration.KeyMgmt.WPA_PSK else dialogView.security.selectedItemPosition + override val ret get() = Arg(WifiConfiguration().apply { + SSID = dialogView.ssid.text.toString() + allowedKeyManagement.set(selectedSecurity) + allowedAuthAlgorithms.set(WifiConfiguration.AuthAlgorithm.OPEN) + if (dialogView.password.length() != 0) preSharedKey = dialogView.password.text.toString() + if (Build.VERSION.SDK_INT >= 23) { + val bandOption = dialogView.band.selectedItem as BandOption + apBand = bandOption.apBand + apChannel = bandOption.apChannel + } + }) + + override fun AlertDialog.Builder.prepare(listener: DialogInterface.OnClickListener) { + val activity = requireActivity() + dialogView = activity.layoutInflater.inflate(R.layout.dialog_wifi_ap, null) + setView(dialogView) + if (!arg.readOnly) setPositiveButton(R.string.wifi_save, listener) + setNegativeButton(R.string.donations__button_close, null) + dialogView.toolbar.inflateMenu(R.menu.toolbar_configuration) + dialogView.toolbar.setOnMenuItemClickListener(this@WifiApDialogFragment) + if (!arg.readOnly) dialogView.ssid.addTextChangedListener(this@WifiApDialogFragment) + if (arg.p2pMode) dialogView.security_wrapper.isGone = true else dialogView.security.apply { + adapter = ArrayAdapter(activity, android.R.layout.simple_spinner_item, 0, + WifiConfiguration.KeyMgmt.strings).apply { + setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item) + } + onItemSelectedListener = object : AdapterView.OnItemSelectedListener { + override fun onNothingSelected(parent: AdapterView<*>?) = + throw IllegalStateException("Must select something") + override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) { + dialogView.password_wrapper.isGone = position == WifiConfiguration.KeyMgmt.NONE + } + } + } + if (!arg.readOnly) dialogView.password.addTextChangedListener(this@WifiApDialogFragment) + if (Build.VERSION.SDK_INT >= 23 || arg.p2pMode) dialogView.band.apply { + bandOptions = mutableListOf().apply { + if (arg.p2pMode) { + add(BandOption.BandAny) + if (Build.VERSION.SDK_INT >= 29) { + add(BandOption.Band2GHz) + add(BandOption.Band5GHz) + } + } else { + if (Build.VERSION.SDK_INT >= 28) add(BandOption.BandAny) + add(BandOption.Band2GHz) + add(BandOption.Band5GHz) + } + addAll(channels) + } + adapter = ArrayAdapter(activity, android.R.layout.simple_spinner_item, 0, bandOptions).apply { + setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item) + } + if (Build.VERSION.SDK_INT < 23) { + setSelection(bandOptions.indexOfFirst { it.apChannel == RepeaterService.operatingChannel }) + } + } else dialogView.band_wrapper.isGone = true + populateFromConfiguration(arg.configuration) + } + + private fun populateFromConfiguration(configuration: WifiConfiguration) { + dialogView.ssid.setText(configuration.SSID) + if (!arg.p2pMode) dialogView.security.setSelection(configuration.apKeyManagement) + dialogView.password.setText(configuration.preSharedKey) + if (Build.VERSION.SDK_INT >= 23) { + dialogView.band.setSelection(if (configuration.apChannel in 1..165) { + bandOptions.indexOfFirst { it.apChannel == configuration.apChannel } + } else bandOptions.indexOfFirst { it.apBand == configuration.apBand }) + } + } + + override fun onStart() { + super.onStart() + started = true + if (!arg.readOnly) validate() + } + + /** + * This function is reached only if not arg.readOnly. + */ + private fun validate() { + if (!started) return + val ssidValid = dialogView.ssid.length() != 0 && + Charset.forName("UTF-8").encode(dialogView.ssid.text.toString()).limit() <= 32 + val passwordValid = when (selectedSecurity) { + WifiConfiguration.KeyMgmt.WPA_PSK, WPA2_PSK -> dialogView.password.length() >= 8 + else -> true // do not try to validate + } + dialogView.password_wrapper.error = if (passwordValid) null else { + requireContext().getString(R.string.credentials_password_too_short) + } + (dialog as AlertDialog).getButton(DialogInterface.BUTTON_POSITIVE).isEnabled = ssidValid && passwordValid + } + + override fun onTextChanged(s: CharSequence, start: Int, before: Int, count: Int) { } + override fun beforeTextChanged(s: CharSequence, start: Int, count: Int, after: Int) { } + override fun afterTextChanged(editable: Editable) = validate() + + override fun onMenuItemClick(item: MenuItem?): Boolean { + return when (item?.itemId) { + android.R.id.copy -> { + app.clipboard.setPrimaryClip(ClipData.newPlainText(null, + Base64.encodeToString(ret.configuration.toByteArray(), BASE64_FLAGS))) + true + } + android.R.id.paste -> try { + app.clipboard.primaryClip?.getItemAt(0)?.text?.apply { + Base64.decode(toString(), BASE64_FLAGS).toParcelable() + ?.let { populateFromConfiguration(it) } + } + true + } catch (e: IllegalArgumentException) { + SmartSnackbar.make(e).show() + false + } + R.id.share_qr -> { + val qrString = try { + ret.configuration.toQRString() + } catch (e: IllegalArgumentException) { + SmartSnackbar.make(e).show() + return false + } + QRCodeDialog().withArg(qrString).show(fragmentManager ?: return false, "QRCodeDialog") + true + } + else -> false + } + } + + override fun onClick(dialog: DialogInterface?, which: Int) { + super.onClick(dialog, which) + if (Build.VERSION.SDK_INT < 23 && arg.p2pMode && which == DialogInterface.BUTTON_POSITIVE) { + RepeaterService.operatingChannel = (dialogView.band.selectedItem as BandOption).apChannel + } + } +} diff --git a/mobile/src/main/java/be/mygod/vpnhotspot/net/wifi/configuration/WifiConfiguration.kt b/mobile/src/main/java/be/mygod/vpnhotspot/net/wifi/configuration/WifiConfiguration.kt new file mode 100644 index 00000000..66b36e38 --- /dev/null +++ b/mobile/src/main/java/be/mygod/vpnhotspot/net/wifi/configuration/WifiConfiguration.kt @@ -0,0 +1,118 @@ +package be.mygod.vpnhotspot.net.wifi.configuration + +import android.net.wifi.WifiConfiguration +import androidx.annotation.RequiresApi +import be.mygod.vpnhotspot.net.wifi.WifiApManager +import timber.log.Timber +import java.lang.reflect.InvocationTargetException + +val WPA2_PSK = WifiConfiguration.KeyMgmt.strings.indexOf("WPA2_PSK") + +/** + * apBand and apChannel is available since API 23. + * + * https://android.googlesource.com/platform/frameworks/base/+/android-6.0.0_r1/wifi/java/android/net/wifi/WifiConfiguration.java#242 + */ +private val apBandField by lazy { WifiConfiguration::class.java.getDeclaredField("apBand") } +private val apChannelField by lazy { WifiConfiguration::class.java.getDeclaredField("apChannel") } + +/** + * 2GHz band. + * + * https://android.googlesource.com/platform/frameworks/base/+/android-7.0.0_r1/wifi/java/android/net/wifi/WifiConfiguration.java#241 + */ +@RequiresApi(23) +const val AP_BAND_2GHZ = 0 +/** + * 5GHz band. + */ +@RequiresApi(23) +const val AP_BAND_5GHZ = 1 +/** + * Device is allowed to choose the optimal band (2Ghz or 5Ghz) based on device capability, + * operating country code and current radio conditions. + * + * Introduced in 9.0, but we will abuse this constant anyway. + * https://android.googlesource.com/platform/frameworks/base/+/android-9.0.0_r1/wifi/java/android/net/wifi/WifiConfiguration.java#295 + */ +@RequiresApi(23) +const val AP_BAND_ANY = -1 + +/** + * The band which AP resides on + * -1:Any 0:2G 1:5G + * By default, 2G is chosen + */ +var WifiConfiguration.apBand: Int + @RequiresApi(23) get() = apBandField.get(this) as Int + @RequiresApi(23) set(value) = apBandField.set(this, value) +/** + * The channel which AP resides on + * 2G 1-11 + * 5G 36,40,44,48,149,153,157,161,165 + * 0 - find a random available channel according to the apBand + */ +var WifiConfiguration.apChannel: Int + @RequiresApi(23) get() = apChannelField.get(this) as Int + @RequiresApi(23) set(value) = apChannelField.set(this, value) + +/** + * The frequency which AP resides on (MHz). Resides in range [2412, 5815]. + */ +fun channelToFrequency(channel: Int) = when (channel) { + in 1..14 -> 2407 + 5 * channel + in 15..165 -> 5000 + 5 * channel + else -> throw IllegalArgumentException("Invalid channel $channel") +} +fun frequencyToChannel(frequency: Int) = when (frequency % 5) { + 2 -> ((frequency - 2407) / 5).also { check(it in 1..14) { "Invalid 2.4 GHz frequency $frequency" } } + 0 -> ((frequency - 5000) / 5).also { check(it in 15..165) { "Invalid 5 GHz frequency $frequency" } } + else -> throw IllegalArgumentException("Invalid frequency $frequency") +} + +val WifiConfiguration.apKeyManagement get() = allowedKeyManagement.nextSetBit(0).let { selected -> + check(allowedKeyManagement.nextSetBit(selected + 1) < 0) { "More than 1 key managements supplied" } + if (selected < 0) WifiConfiguration.KeyMgmt.NONE else selected // getAuthType returns NONE if nothing is selected +} + +private val qrSanitizer = Regex("([\\\\\":;,])") +/** + * Documentation: https://github.com/zxing/zxing/wiki/Barcode-Contents#wi-fi-network-config-android-ios-11 + */ +fun WifiConfiguration.toQRString() = StringBuilder("WIFI:").apply { + fun String.sanitize() = qrSanitizer.replace(this) { "\\${it.groupValues[1]}" } + var password = true + when (apKeyManagement) { + WifiConfiguration.KeyMgmt.NONE -> password = false + WifiConfiguration.KeyMgmt.WPA_PSK, WifiConfiguration.KeyMgmt.WPA_EAP, WPA2_PSK -> append("T:WPA;") + else -> throw IllegalArgumentException("Unsupported authentication type") + } + append("S:") + append(SSID.sanitize()) + append(';') + if (password) { + append("P:") + append(preSharedKey.sanitize()) + append(';') + } + if (hiddenSSID) append("H:true;") + append(';') +}.toString() + +/** + * Based on: + * https://android.googlesource.com/platform/packages/apps/Settings/+/android-5.0.0_r1/src/com/android/settings/wifi/WifiApDialog.java#88 + * https://android.googlesource.com/platform/packages/apps/Settings/+/b1af85d/src/com/android/settings/wifi/tether/WifiTetherSettings.java#162 + */ +fun newWifiApConfiguration(ssid: String, passphrase: String?) = try { + WifiApManager.configuration +} catch (e: InvocationTargetException) { + if (e.targetException !is SecurityException) Timber.w(e) + WifiConfiguration() +}.apply { + SSID = ssid + preSharedKey = passphrase + allowedKeyManagement.clear() + allowedAuthAlgorithms.clear() + allowedAuthAlgorithms.set(WifiConfiguration.AuthAlgorithm.OPEN) +} diff --git a/mobile/src/main/java/be/mygod/vpnhotspot/room/ClientRecord.kt b/mobile/src/main/java/be/mygod/vpnhotspot/room/ClientRecord.kt index 786e6dc6..4504eca1 100644 --- a/mobile/src/main/java/be/mygod/vpnhotspot/room/ClientRecord.kt +++ b/mobile/src/main/java/be/mygod/vpnhotspot/room/ClientRecord.kt @@ -1,6 +1,7 @@ package be.mygod.vpnhotspot.room import androidx.lifecycle.LiveData +import androidx.lifecycle.map import androidx.room.* @Entity @@ -12,12 +13,12 @@ data class ClientRecord(@PrimaryKey @androidx.room.Dao abstract class Dao { @Query("SELECT * FROM `ClientRecord` WHERE `mac` = :mac") - abstract suspend fun lookup(mac: Long): ClientRecord? - + protected abstract suspend fun lookup(mac: Long): ClientRecord? suspend fun lookupOrDefault(mac: Long) = lookup(mac) ?: ClientRecord(mac) @Query("SELECT * FROM `ClientRecord` WHERE `mac` = :mac") - abstract fun lookupSync(mac: Long): LiveData + protected abstract fun lookupSync(mac: Long): LiveData + fun lookupOrDefaultSync(mac: Long) = lookupSync(mac).map { it ?: ClientRecord(mac) } @Insert(onConflict = OnConflictStrategy.REPLACE) protected abstract suspend fun updateInternal(value: ClientRecord): Long diff --git a/mobile/src/main/java/be/mygod/vpnhotspot/room/Converters.kt b/mobile/src/main/java/be/mygod/vpnhotspot/room/Converters.kt index 9a4c95de..4199a33b 100644 --- a/mobile/src/main/java/be/mygod/vpnhotspot/room/Converters.kt +++ b/mobile/src/main/java/be/mygod/vpnhotspot/room/Converters.kt @@ -1,8 +1,8 @@ package be.mygod.vpnhotspot.room -import android.os.Parcel import android.text.TextUtils import androidx.room.TypeConverter +import be.mygod.vpnhotspot.util.useParcel import java.net.InetAddress import java.nio.ByteBuffer import java.nio.ByteOrder @@ -10,27 +10,17 @@ import java.nio.ByteOrder object Converters { @JvmStatic @TypeConverter - fun persistCharSequence(cs: CharSequence): ByteArray { - val p = Parcel.obtain() - try { - TextUtils.writeToParcel(cs, p, 0) - return p.marshall() - } finally { - p.recycle() - } + fun persistCharSequence(cs: CharSequence) = useParcel { p -> + TextUtils.writeToParcel(cs, p, 0) + p.marshall() } @JvmStatic @TypeConverter - fun unpersistCharSequence(data: ByteArray): CharSequence { - val p = Parcel.obtain() - try { - p.unmarshall(data, 0, data.size) - p.setDataPosition(0) - return TextUtils.CHAR_SEQUENCE_CREATOR.createFromParcel(p) - } finally { - p.recycle() - } + fun unpersistCharSequence(data: ByteArray) = useParcel { p -> + p.unmarshall(data, 0, data.size) + p.setDataPosition(0) + TextUtils.CHAR_SEQUENCE_CREATOR.createFromParcel(p) } @JvmStatic diff --git a/mobile/src/main/java/be/mygod/vpnhotspot/util/QRCodeDialog.kt b/mobile/src/main/java/be/mygod/vpnhotspot/util/QRCodeDialog.kt new file mode 100644 index 00000000..34344c83 --- /dev/null +++ b/mobile/src/main/java/be/mygod/vpnhotspot/util/QRCodeDialog.kt @@ -0,0 +1,26 @@ +package be.mygod.vpnhotspot.util + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.ViewGroup +import android.widget.ImageView +import androidx.core.os.bundleOf +import androidx.fragment.app.DialogFragment +import be.mygod.vpnhotspot.R +import net.glxn.qrgen.android.QRCode + +class QRCodeDialog : DialogFragment() { + companion object { + private const val KEY_ARG = "arg" + } + + fun withArg(arg: String) = apply { arguments = bundleOf(KEY_ARG to arg) } + private val arg get() = arguments?.getString(KEY_ARG) + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?) = + ImageView(context).apply { + val size = resources.getDimensionPixelSize(R.dimen.qr_code_size) + layoutParams = ViewGroup.LayoutParams(size, size) + setImageBitmap((QRCode.from(arg).withSize(size, size) as QRCode).bitmap()) + } +} diff --git a/mobile/src/main/java/be/mygod/vpnhotspot/util/SpanFormatter.kt b/mobile/src/main/java/be/mygod/vpnhotspot/util/SpanFormatter.kt index 9cb55aae..e3d056fe 100644 --- a/mobile/src/main/java/be/mygod/vpnhotspot/util/SpanFormatter.kt +++ b/mobile/src/main/java/be/mygod/vpnhotspot/util/SpanFormatter.kt @@ -57,7 +57,7 @@ object SpanFormatter { i = m.start() val exprEnd = m.end() - val argTerm = m.group(1) + val argTerm = m.group(1)!! val modTerm = m.group(2) val cookedArg = when (val typeTerm = m.group(3)) { diff --git a/mobile/src/main/java/be/mygod/vpnhotspot/util/Utils.kt b/mobile/src/main/java/be/mygod/vpnhotspot/util/Utils.kt index 42f174ef..02dd7b96 100644 --- a/mobile/src/main/java/be/mygod/vpnhotspot/util/Utils.kt +++ b/mobile/src/main/java/be/mygod/vpnhotspot/util/Utils.kt @@ -1,7 +1,11 @@ package be.mygod.vpnhotspot.util +import android.annotation.SuppressLint import android.content.* +import android.net.InetAddresses import android.os.Build +import android.os.Parcel +import android.os.Parcelable import android.text.Spannable import android.text.SpannableString import android.text.SpannableStringBuilder @@ -15,7 +19,6 @@ import androidx.databinding.BindingAdapter import be.mygod.vpnhotspot.App.Companion.app import be.mygod.vpnhotspot.room.macToString import be.mygod.vpnhotspot.widget.SmartSnackbar -import java.lang.RuntimeException import java.net.InetAddress import java.net.NetworkInterface import java.net.SocketException @@ -32,6 +35,25 @@ fun Long.toPluralInt(): Int { return (this % 1000000000).toInt() + 1000000000 } +@SuppressLint("Recycle") +fun useParcel(block: (Parcel) -> T) = Parcel.obtain().run { + try { + block(this) + } finally { + recycle() + } +} + +fun Parcelable.toByteArray(parcelableFlags: Int = 0) = useParcel { p -> + p.writeParcelable(this, parcelableFlags) + p.marshall() +} +fun ByteArray.toParcelable() = useParcel { p -> + p.unmarshall(this, 0, size) + p.setDataPosition(0) + p.readParcelable(javaClass.classLoader) +} + fun broadcastReceiver(receiver: (Context, Intent) -> Unit) = object : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent) = receiver(context, intent) } @@ -72,12 +94,13 @@ fun NetworkInterface.formatAddresses(macOnly: Boolean = false) = SpannableString } }.trimEnd() -private val parseNumericAddress by lazy { +private val parseNumericAddress by lazy @SuppressLint("SoonBlockedPrivateApi") { InetAddress::class.java.getDeclaredMethod("parseNumericAddress", String::class.java).apply { isAccessible = true } } -fun parseNumericAddress(address: String) = parseNumericAddress.invoke(null, address) as InetAddress +fun parseNumericAddress(address: String) = if (Build.VERSION.SDK_INT >= 29) + InetAddresses.parseNumericAddress(address) else parseNumericAddress.invoke(null, address) as InetAddress fun Context.launchUrl(url: String) { if (app.hasTouch) try { diff --git a/mobile/src/main/res/drawable/ic_content_wave.xml b/mobile/src/main/res/drawable/ic_content_wave.xml deleted file mode 100644 index 68e040eb..00000000 --- a/mobile/src/main/res/drawable/ic_content_wave.xml +++ /dev/null @@ -1,11 +0,0 @@ - - - - - diff --git a/mobile/src/main/res/drawable/ic_social_share.xml b/mobile/src/main/res/drawable/ic_social_share.xml new file mode 100644 index 00000000..479da079 --- /dev/null +++ b/mobile/src/main/res/drawable/ic_social_share.xml @@ -0,0 +1,6 @@ + + + diff --git a/mobile/src/main/res/layout/activity_main.xml b/mobile/src/main/res/layout/activity_main.xml index 8afd3ff0..e96761d8 100644 --- a/mobile/src/main/res/layout/activity_main.xml +++ b/mobile/src/main/res/layout/activity_main.xml @@ -10,7 +10,7 @@ android:orientation="vertical" tools:context="be.mygod.vpnhotspot.MainActivity"> - - - + - - + + - + + - + + + + - - - + + + + - - - + android:layout_marginTop="8dip" + app:passwordToggleEnabled="true" + app:errorEnabled="true"> + + + + + + + + + diff --git a/mobile/src/main/res/layout/listitem_repeater.xml b/mobile/src/main/res/layout/listitem_repeater.xml index 12fe3340..a1b10079 100644 --- a/mobile/src/main/res/layout/listitem_repeater.xml +++ b/mobile/src/main/res/layout/listitem_repeater.xml @@ -3,6 +3,7 @@ xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools"> + @@ -40,7 +41,7 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + android:visibility="@{data.serviceStarted && WifiP2pManagerHelper.startWps != null}"> + + + + + diff --git a/mobile/src/main/res/menu/toolbar_tethering.xml b/mobile/src/main/res/menu/toolbar_tethering.xml index e1799d11..d67e6f58 100644 --- a/mobile/src/main/res/menu/toolbar_tethering.xml +++ b/mobile/src/main/res/menu/toolbar_tethering.xml @@ -9,4 +9,18 @@ app:showAsAction="always"> + + + + + + + + diff --git a/mobile/src/main/res/values-ru/strings.xml b/mobile/src/main/res/values-ru/strings.xml index 9063e6e8..bdc29199 100644 --- a/mobile/src/main/res/values-ru/strings.xml +++ b/mobile/src/main/res/values-ru/strings.xml @@ -1,11 +1,11 @@ + - Настройка Wi-Fi ретранслятора Действительный конфиг не найден. Пожалуйста, сначала запустите ретранслятор. - Сброс - Сброс учетных данных. - Не удалось сбросить учетные данные (причина:%s) Не удалось удалить избыточную группу P2P (причина: %s) Wi-Fi директ недоступен, пожалуйста включите Wi-Fi @@ -19,7 +19,9 @@ неподдерживаемая операция Сервис недоступен. Попробуйте позже - + "USB-модем" + "Точка доступа Wi‑Fi" + "Bluetooth-модем" " (подключение)" " (доступный)" @@ -44,17 +46,24 @@ Ошибка: Нисходящий интерфейс не найден Что-то пошло не так, пожалуйста, проверьте отладочную информацию. + Настройка Wi-Fi ретранслятора + "Имя сети" + "Защита" + "Пароль" Пароль должен содержать не менее 8 символов. + "Диапазон частот Wi-Fi" + "Авто" + "2,4 ГГц" + "5,0 ГГц" + "Сохранить" Закрыть Считаете это приложение полезным?\nПоддержите его разработку, отправив пожертвование разработчику! Google Play Store - In-App пожертвования не поддерживаются. Пожертвования через приложение не поддерживаются. Google Play Store установлен правильно? Google взимает 30% комиссии с каждого пожертвования! Пожертвовать! Сколько? - Благодарю! Благодарю за пожертвование! Я очень это ценю! diff --git a/mobile/src/main/res/values-v29/arrays.xml b/mobile/src/main/res/values-v29/arrays.xml new file mode 100644 index 00000000..30824b47 --- /dev/null +++ b/mobile/src/main/res/values-v29/arrays.xml @@ -0,0 +1,13 @@ + + + + @string/settings_service_wifi_lock_none + @string/settings_service_wifi_lock_high_perf_v29 + @string/settings_service_wifi_lock_low_latency + + + None + HighPerf + LowLatency + + diff --git a/mobile/src/main/res/values-zh-rCN/strings.xml b/mobile/src/main/res/values-zh-rCN/strings.xml index c5c46b1c..17eaee4f 100644 --- a/mobile/src/main/res/values-zh-rCN/strings.xml +++ b/mobile/src/main/res/values-zh-rCN/strings.xml @@ -6,17 +6,14 @@ 已连设备 设置选项 + 无线中继 (%1$d MHz, 频道 %2$d) WPS(不安全) 输入 PIN 一键加密 请在 2 分钟内在需要连接的设备上使用一键加密以连接到此中继。 成功注册 PIN。 打开 WPS 失败(原因:%s) - 设置 WLAN 中继 未能找到有效的档案。请尝试先打开中继。 - 重置 - 凭据已重置。 - 重置凭据失败(原因:%s) 删除多余 P2P 群组失败(原因:%s) Wi\u2011Fi 直连不可用,请打开 Wi\u2011Fi @@ -47,7 +44,7 @@ 关闭 Google Play 商店 - 不支持 In-App 捐赠。 不支持 In-App 捐赠。你的 Google Play 商店是否安装正确了呢? 捐赠! 捐赠多少? - 谢谢! 谢谢捐赠!\n非常感谢您! 觉得此应用很有用?\n捐赠给该开发者以支持此应用的开发! diff --git a/mobile/src/main/res/values/dimen.xml b/mobile/src/main/res/values/dimen.xml index 7cc70bcc..3a4f06a6 100644 --- a/mobile/src/main/res/values/dimen.xml +++ b/mobile/src/main/res/values/dimen.xml @@ -1,4 +1,5 @@ 56dp + 250dp diff --git a/mobile/src/main/res/values/strings.xml b/mobile/src/main/res/values/strings.xml index ee7c4a8a..bb1eb044 100644 --- a/mobile/src/main/res/values/strings.xml +++ b/mobile/src/main/res/values/strings.xml @@ -2,8 +2,7 @@ Close Do you find this application useful?\nSupport its development by sending a donation to the developer! Google Play Store - In-App Donations are not supported. In-App Donations are not supported. Is Google Play Store installed correctly? Google charges a fee of 30% Donate! How much? - Thanks! Thanks for donating!\nI really appreciate this! diff --git a/mobile/src/main/res/values/styles.xml b/mobile/src/main/res/values/styles.xml index 87353f85..3a9273fc 100644 --- a/mobile/src/main/res/values/styles.xml +++ b/mobile/src/main/res/values/styles.xml @@ -13,7 +13,6 @@ + +