Merge branch 'master' into temp-hotspot-use-system
This commit is contained in:
@@ -3,7 +3,7 @@ jobs:
|
||||
build:
|
||||
working_directory: ~/code
|
||||
docker:
|
||||
- image: circleci/android:api-30
|
||||
- image: cimg/android:2023.02.1
|
||||
environment:
|
||||
GRADLE_OPTS: -Dorg.gradle.workers.max=1 -Dorg.gradle.daemon=false -Dkotlin.compiler.execution.strategy="in-process"
|
||||
steps:
|
||||
|
||||
119
README.md
119
README.md
@@ -1,15 +1,19 @@
|
||||
# VPN Hotspot
|
||||
|
||||
[](https://circleci.com/gh/Mygod/VPNHotspot)
|
||||
[](https://android-arsenal.com/api?level=21)
|
||||
[](https://android-arsenal.com/api?level=28)
|
||||
[](https://github.com/Mygod/VPNHotspot/releases)
|
||||
[](https://github.com/Mygod/VPNHotspot/search?l=kotlin)
|
||||
[](https://www.codacy.com/app/Mygod/VPNHotspot?utm_source=github.com&utm_medium=referral&utm_content=Mygod/VPNHotspot&utm_campaign=Badge_Grade)
|
||||
[](https://www.codacy.com/gh/Mygod/VPNHotspot/dashboard?utm_source=github.com&utm_medium=referral&utm_content=Mygod/VPNHotspot&utm_campaign=Badge_Grade)
|
||||
[](LICENSE)
|
||||
|
||||
Connecting things to your VPN made simple. Share your VPN connection over hotspot or repeater. (**root required**)
|
||||
<a href="https://play.google.com/store/apps/details?id=be.mygod.vpnhotspot" target="_blank"><img src="https://play.google.com/intl/en_us/badges/images/generic/en-play-badge.png" height="60"></a>,
|
||||
<a href="https://appdistribution.firebase.dev/i/FUCPGdzm" target="_blank">sign up for beta</a>
|
||||
|
||||
| Release channel | [GitHub](https://github.com/Mygod/VPNHotspot/releases) | [Google Play](https://play.google.com/store/apps/details?id=be.mygod.vpnhotspot) ([beta](https://play.google.com/apps/testing/be.mygod.vpnhotspot)) |
|
||||
|---------------------------------------------------------|:------------------------------------------------------:|:---------------------------------------------------------------------------------------------------------------------------------------------------:|
|
||||
| Auto update | Email updates via watching releases | ✓ |
|
||||
| In-app update channel | GitHub | Google Play |
|
||||
| [Sponsor/Donation](https://github.com/sponsors/Mygod) | ✓ | Google Play In-App Purchases only |
|
||||
|
||||
This app is useful for:
|
||||
|
||||
@@ -69,7 +73,7 @@ Default settings are picked to suit general use cases and maximize compatibility
|
||||
I find turning this option off sometimes works better for dummy VPNs like ad-blockers and socksifiers than Simple mode, e.g. Shadowsocks.
|
||||
But you should never use this for real VPNs like OpenVPN, etc.
|
||||
- Simple: Source address/port from downstream packets will be remapped and that's about it.
|
||||
- (since Android 9) Android Netd Service:
|
||||
- Android Netd Service:
|
||||
Let your system handle masquerade.
|
||||
Android system will do a few extra things to make things like FTP and tethering traffic counter work.
|
||||
You should probably not use this if you are trying to hide your tethering activity from your carrier.
|
||||
@@ -78,7 +82,7 @@ Default settings are picked to suit general use cases and maximize compatibility
|
||||
|
||||
* Disable IPv6 tethering: Turning this option on will disable IPv6 for system tethering. Useful for stopping IPv6 leaks
|
||||
as this app currently doesn't handle IPv6 VPN tethering (see [#6](https://github.com/Mygod/VPNHotspot/issues/6)).
|
||||
* (since Android 8.1) Tethering hardware acceleration:
|
||||
* Tethering hardware acceleration:
|
||||
This is a shortcut to the same setting in system Developer options.
|
||||
Turning this option off is probably a must for making VPN tethering over system tethering work,
|
||||
but it might also decrease your battery life while tethering is enabled.
|
||||
@@ -146,58 +150,56 @@ _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.
|
||||
API restrictions are updated up to [commit `ebe7044`](https://android.googlesource.com/platform/prebuilts/runtime/+/ebe7044/appcompat/hiddenapi-flags.csv).
|
||||
API restrictions are updated up to [SHA-256 checksum `233a277aa8ac475b6df61bffd95665d86aac6eb2ad187b90bf42a98f5f2a11a3`](https://dl.google.com/developers/android/tm/non-sdk/hiddenapi-flags.csv).
|
||||
|
||||
Greylisted/blacklisted APIs or internal constants: (some constants are hardcoded or implicitly used)
|
||||
|
||||
* (prior to API 30) `Landroid/net/ConnectivityManager;->getLastTetherError(Ljava/lang/String;)I,max-target-r`
|
||||
* (since API 30) `Landroid/net/ConnectivityModuleConnector;->IN_PROCESS_SUFFIX:Ljava/lang/String;`
|
||||
* (since API 30) `Landroid/net/TetheringManager$TetheringEventCallback;->onTetherableInterfaceRegexpsChanged(Landroid/net/TetheringManager$TetheringInterfaceRegexps;)V,blocked`
|
||||
* (since API 30) `Landroid/net/TetheringManager;->TETHERING_WIGIG:I,blocked`
|
||||
* (since API 31) `Landroid/net/wifi/SoftApConfiguration$Builder;->setUserConfiguration(Z)Landroid/net/wifi/SoftApConfiguration$Builder;,blocked`
|
||||
* (since API 31) `Landroid/net/TetheringManager$TetheringEventCallback;->onSupportedTetheringTypes(Ljava/util/Set;)V,blocked`
|
||||
* (since API 31) `Landroid/net/wifi/SoftApCapability;->getCountryCode()Ljava/lang/String;,blocked`
|
||||
* (since API 33) `Landroid/net/wifi/SoftApConfiguration$Builder;->setRandomizedMacAddress(Landroid/net/MacAddress;)Landroid/net/wifi/SoftApConfiguration$Builder;,blocked`
|
||||
* (since API 31) `Landroid/net/wifi/SoftApConfiguration;->BAND_TYPES:[I,blocked`
|
||||
* (since API 31) `Landroid/net/wifi/SoftApInfo;->getApInstanceIdentifier()Ljava/lang/String;,blocked`
|
||||
* (since API 31) `Landroid/net/wifi/WifiClient;->getApInstanceIdentifier()Ljava/lang/String;,blocked`
|
||||
* (prior to API 30) `Landroid/net/wifi/WifiConfiguration$KeyMgmt;->FT_PSK:I,lo-prio,max-target-o`
|
||||
* (prior to API 30) `Landroid/net/wifi/WifiConfiguration$KeyMgmt;->WPA_PSK_SHA256:I,blocked`
|
||||
* (since API 23, prior to API 30) `Landroid/net/wifi/WifiConfiguration;->AP_BAND_2GHZ:I,lo-prio,max-target-o`
|
||||
* (since API 23, prior to API 30) `Landroid/net/wifi/WifiConfiguration;->AP_BAND_5GHZ:I,lo-prio,max-target-o`
|
||||
* (since API 28, prior to API 30) `Landroid/net/wifi/WifiConfiguration;->AP_BAND_ANY:I,lo-prio,max-target-o`
|
||||
* (since API 23, prior to API 30) `Landroid/net/wifi/WifiConfiguration;->apBand:I,unsupported`
|
||||
* (since API 23, prior to API 30) `Landroid/net/wifi/WifiConfiguration;->apChannel:I,unsupported`
|
||||
* (since API 28, prior to API 30) `Landroid/net/wifi/WifiManager$SoftApCallback;->onNumClientsChanged(I)V,greylist-max-o`
|
||||
* (since API 26) `Landroid/net/wifi/WifiManager;->cancelLocalOnlyHotspotRequest()V,unsupported`
|
||||
* (prior to API 26) `Landroid/net/wifi/WifiManager;->setWifiApEnabled(Landroid/net/wifi/WifiConfiguration;Z)Z`
|
||||
* (prior to API 30) `Landroid/net/wifi/WifiConfiguration;->AP_BAND_2GHZ:I,lo-prio,max-target-o`
|
||||
* (prior to API 30) `Landroid/net/wifi/WifiConfiguration;->AP_BAND_5GHZ:I,lo-prio,max-target-o`
|
||||
* (prior to API 30) `Landroid/net/wifi/WifiConfiguration;->AP_BAND_ANY:I,lo-prio,max-target-o`
|
||||
* (prior to API 30) `Landroid/net/wifi/WifiConfiguration;->apBand:I,unsupported`
|
||||
* (prior to API 30) `Landroid/net/wifi/WifiConfiguration;->apChannel:I,unsupported`
|
||||
* (prior to API 30) `Landroid/net/wifi/WifiManager$SoftApCallback;->onNumClientsChanged(I)V,greylist-max-o`
|
||||
* `Landroid/net/wifi/WifiManager;->cancelLocalOnlyHotspotRequest()V,unsupported`
|
||||
* `Landroid/net/wifi/p2p/WifiP2pConfig$Builder;->MAC_ANY_ADDRESS:Landroid/net/MacAddress;,blocked`
|
||||
* (since API 29) `Landroid/net/wifi/p2p/WifiP2pConfig$Builder;->mNetworkName:Ljava/lang/String;,blocked`
|
||||
* `Landroid/net/wifi/p2p/WifiP2pManager;->startWps(Landroid/net/wifi/p2p/WifiP2pManager$Channel;Landroid/net/wifi/WpsInfo;Landroid/net/wifi/p2p/WifiP2pManager$ActionListener;)V,max-target-r`
|
||||
* (since API 28, prior to API 30) `Landroid/provider/Settings$Global;->SOFT_AP_TIMEOUT_ENABLED:Ljava/lang/String;,lo-prio,max-target-o`
|
||||
* `Landroid/net/wifi/p2p/WifiP2pManager;->startWps(Landroid/net/wifi/p2p/WifiP2pManager$Channel;Landroid/net/wifi/WpsInfo;Landroid/net/wifi/p2p/WifiP2pManager$ActionListener;)V,unsupported`
|
||||
* (prior to API 30) `Landroid/provider/Settings$Global;->SOFT_AP_TIMEOUT_ENABLED:Ljava/lang/String;,lo-prio,max-target-o`
|
||||
* (prior to API 30) `Lcom/android/internal/R$array;->config_tether_bluetooth_regexs:I,max-target-q`
|
||||
* (prior to API 30) `Lcom/android/internal/R$array;->config_tether_usb_regexs:I,max-target-q`
|
||||
* (prior to API 30) `Lcom/android/internal/R$array;->config_tether_wifi_regexs:I,max-target-q`
|
||||
* (on API 29) `Lcom/android/internal/R$bool;->config_wifi_p2p_mac_randomization_supported:I,blacklist`
|
||||
* (since API 28, prior to API 30) `Lcom/android/internal/R$integer;->config_wifi_framework_soft_ap_timeout_delay:I,greylist-max-o`
|
||||
* (prior to API 30) `Lcom/android/internal/R$integer;->config_wifi_framework_soft_ap_timeout_delay:I,greylist-max-o`
|
||||
* `Lcom/android/internal/R$string;->config_ethernet_iface_regex:I,lo-prio,max-target-o`
|
||||
* (since API 27) `Lcom/android/server/connectivity/tethering/OffloadHardwareInterface;->DEFAULT_TETHER_OFFLOAD_DISABLED:I`
|
||||
* (since API 30) `Lcom/android/server/wifi/WifiContext;->ACTION_RESOURCES_APK:Ljava/lang/String;`
|
||||
* (since API 29) `Lcom/android/server/wifi/p2p/WifiP2pServiceImpl;->ANONYMIZED_DEVICE_ADDRESS:Ljava/lang/String;`
|
||||
* (since API 30) `Lcom/android/server/SystemServer;->TETHERING_CONNECTOR_CLASS:Ljava/lang/String;`
|
||||
* (since API 29) `Ldalvik/system/VMDebug;->allowHiddenApiReflectionFrom(Ljava/lang/Class;)V,unsupported`
|
||||
* (since API 26) `Ljava/lang/invoke/MethodHandles$Lookup;-><init>(Ljava/lang/Class;I)V,unsupported`
|
||||
* (since API 26) `Ljava/lang/invoke/MethodHandles$Lookup;->ALL_MODES:I,lo-prio,max-target-o`
|
||||
* `Ljava/lang/invoke/MethodHandles$Lookup;-><init>(Ljava/lang/Class;I)V,unsupported`
|
||||
* `Ljava/lang/invoke/MethodHandles$Lookup;->ALL_MODES:I,lo-prio,max-target-o`
|
||||
* (prior to API 29) `Ljava/net/InetAddress;->parseNumericAddress(Ljava/lang/String;)Ljava/net/InetAddress;,core-platform-api,max-target-p`
|
||||
|
||||
<details>
|
||||
<summary>Hidden whitelisted APIs: (same catch as above, however, things in this list are less likely to be broken)</summary>
|
||||
|
||||
* (since API 24) `Landroid/bluetooth/BluetoothPan;->isTetheringOn()Z,sdk,system-api,test-api`
|
||||
* (since API 24) `Landroid/bluetooth/BluetoothProfile;->PAN:I,sdk,system-api,test-api`
|
||||
* `Landroid/bluetooth/BluetoothPan;->isTetheringOn()Z,sdk,system-api,test-api`
|
||||
* `Landroid/bluetooth/BluetoothProfile;->PAN:I,sdk,system-api,test-api`
|
||||
* (since API 30) `Landroid/content/Context;->TETHERING_SERVICE:Ljava/lang/String;,sdk,system-api,test-api`
|
||||
* (since API 24, prior to API 30) `Landroid/net/ConnectivityManager$OnStartTetheringCallback;-><init>()V,sdk,system-api,test-api`
|
||||
* (since API 24, prior to API 30) `Landroid/net/ConnectivityManager$OnStartTetheringCallback;->onTetheringFailed()V,sdk,system-api,test-api`
|
||||
* (since API 24, prior to API 30) `Landroid/net/ConnectivityManager$OnStartTetheringCallback;->onTetheringStarted()V,sdk,system-api,test-api`
|
||||
* (since API 24, prior to API 30) `Landroid/net/ConnectivityManager;->startTethering(IZLandroid/net/ConnectivityManager$OnStartTetheringCallback;Landroid/os/Handler;)V,sdk,system-api,test-api`
|
||||
* (since API 24, prior to API 30) `Landroid/net/ConnectivityManager;->stopTethering(I)V,sdk,system-api,test-api`
|
||||
* (prior to API 30) `Landroid/net/ConnectivityManager$OnStartTetheringCallback;-><init>()V,sdk,system-api,test-api`
|
||||
* (prior to API 30) `Landroid/net/ConnectivityManager$OnStartTetheringCallback;->onTetheringFailed()V,sdk,system-api,test-api`
|
||||
* (prior to API 30) `Landroid/net/ConnectivityManager$OnStartTetheringCallback;->onTetheringStarted()V,sdk,system-api,test-api`
|
||||
* (prior to API 30) `Landroid/net/ConnectivityManager;->startTethering(IZLandroid/net/ConnectivityManager$OnStartTetheringCallback;Landroid/os/Handler;)V,sdk,system-api,test-api`
|
||||
* (prior to API 30) `Landroid/net/ConnectivityManager;->stopTethering(I)V,sdk,system-api,test-api`
|
||||
* `Landroid/net/LinkProperties;->getAllInterfaceNames()Ljava/util/List;,sdk,system-api,test-api`
|
||||
* `Landroid/net/LinkProperties;->getAllRoutes()Ljava/util/List;,sdk,system-api,test-api`
|
||||
* (since API 30) `Landroid/net/TetheringManager$StartTetheringCallback;->onTetheringFailed(I)V,sdk,system-api,test-api`
|
||||
@@ -214,14 +216,14 @@ Greylisted/blacklisted APIs or internal constants: (some constants are hardcoded
|
||||
* (since API 30) `Landroid/net/TetheringManager$TetheringRequest$Builder;->setExemptFromEntitlementCheck(Z)Landroid/net/TetheringManager$TetheringRequest$Builder;,sdk,system-api,test-api`
|
||||
* (since API 30) `Landroid/net/TetheringManager$TetheringRequest$Builder;->setShouldShowEntitlementUi(Z)Landroid/net/TetheringManager$TetheringRequest$Builder;,sdk,system-api,test-api`
|
||||
* `Landroid/net/TetheringManager;->ACTION_TETHER_STATE_CHANGED:Ljava/lang/String;,sdk,system-api,test-api`
|
||||
* (since API 26) `Landroid/net/TetheringManager;->EXTRA_ACTIVE_LOCAL_ONLY:Ljava/lang/String;,sdk,system-api,test-api`
|
||||
* `Landroid/net/TetheringManager;->EXTRA_ACTIVE_LOCAL_ONLY:Ljava/lang/String;,sdk,system-api,test-api`
|
||||
* `Landroid/net/TetheringManager;->EXTRA_ACTIVE_TETHER:Ljava/lang/String;,sdk,system-api,test-api`
|
||||
* `Landroid/net/TetheringManager;->EXTRA_ERRORED_TETHER:Ljava/lang/String;,sdk,system-api,test-api`
|
||||
* (since API 24) `Landroid/net/TetheringManager;->TETHERING_BLUETOOTH:I,sdk,system-api,test-api`
|
||||
* `Landroid/net/TetheringManager;->TETHERING_BLUETOOTH:I,sdk,system-api,test-api`
|
||||
* (since API 30) `Landroid/net/TetheringManager;->TETHERING_ETHERNET:I,sdk,system-api,test-api`
|
||||
* (since API 30) `Landroid/net/TetheringManager;->TETHERING_NCM:I,sdk,system-api,test-api`
|
||||
* (since API 24) `Landroid/net/TetheringManager;->TETHERING_USB:I,sdk,system-api,test-api`
|
||||
* (since API 24) `Landroid/net/TetheringManager;->TETHERING_WIFI:I,sdk,system-api,test-api`
|
||||
* `Landroid/net/TetheringManager;->TETHERING_USB:I,sdk,system-api,test-api`
|
||||
* `Landroid/net/TetheringManager;->TETHERING_WIFI:I,sdk,system-api,test-api`
|
||||
* (since API 31) `Landroid/net/TetheringManager;->TETHERING_WIFI_P2P:I,sdk,system-api,test-api`
|
||||
* `Landroid/net/TetheringManager;->TETHER_ERROR_*:I,sdk,system-api,test-api`
|
||||
* (since API 30) `Landroid/net/TetheringManager;->TETHER_ERROR_NO_CHANGE_TETHERING_PERMISSION:I,sdk,system-api,test-api`
|
||||
* (since API 30) `Landroid/net/TetheringManager;->TETHER_HARDWARE_OFFLOAD_FAILED:I,sdk,system-api,test-api`
|
||||
@@ -242,48 +244,63 @@ Greylisted/blacklisted APIs or internal constants: (some constants are hardcoded
|
||||
* (since API 30) `Landroid/net/wifi/SoftApConfiguration$Builder;-><init>()V,sdk,system-api,test-api`
|
||||
* (since API 30) `Landroid/net/wifi/SoftApConfiguration$Builder;-><init>(Landroid/net/wifi/SoftApConfiguration;)V,sdk,system-api,test-api`
|
||||
* (since API 30) `Landroid/net/wifi/SoftApConfiguration$Builder;->build()Landroid/net/wifi/SoftApConfiguration;,sdk,system-api,test-api`
|
||||
* (since API 33) `Landroid/net/wifi/SoftApConfiguration$Builder;->setAllowedAcsChannels(I[I)Landroid/net/wifi/SoftApConfiguration$Builder;,sdk,system-api,test-api`
|
||||
* (since API 30) `Landroid/net/wifi/SoftApConfiguration$Builder;->setAllowedClientList(Ljava/util/List;)Landroid/net/wifi/SoftApConfiguration$Builder;,sdk,system-api,test-api`
|
||||
* (since API 30) `Landroid/net/wifi/SoftApConfiguration$Builder;->setAutoShutdownEnabled(Z)Landroid/net/wifi/SoftApConfiguration$Builder;,sdk,system-api,test-api`
|
||||
* (on API 30) `Landroid/net/wifi/SoftApConfiguration$Builder;->setBand(I)Landroid/net/wifi/SoftApConfiguration$Builder;,sdk,system-api,test-api`
|
||||
* (since API 30) `Landroid/net/wifi/SoftApConfiguration$Builder;->setBlockedClientList(Ljava/util/List;)Landroid/net/wifi/SoftApConfiguration$Builder;,sdk,system-api,test-api`
|
||||
* (since API 31) `Landroid/net/wifi/SoftApConfiguration$Builder;->setBridgedModeOpportunisticShutdownEnabled(Z)Landroid/net/wifi/SoftApConfiguration$Builder;,sdk,system-api,test-api`
|
||||
* (since API 33) `Landroid/net/wifi/SoftApConfiguration$Builder;->setBridgedModeOpportunisticShutdownTimeoutMillis(J)Landroid/net/wifi/SoftApConfiguration$Builder;,sdk,system-api,test-api`
|
||||
* (since API 30) `Landroid/net/wifi/SoftApConfiguration$Builder;->setBssid(Landroid/net/MacAddress;)Landroid/net/wifi/SoftApConfiguration$Builder;,sdk,system-api,test-api`
|
||||
* (on API 30) `Landroid/net/wifi/SoftApConfiguration$Builder;->setChannel(II)Landroid/net/wifi/SoftApConfiguration$Builder;,sdk,system-api,test-api`
|
||||
* (since API 31) `Landroid/net/wifi/SoftApConfiguration$Builder;->setChannels(Landroid/util/SparseIntArray;)Landroid/net/wifi/SoftApConfiguration$Builder;,sdk,system-api,test-api`
|
||||
* (since API 30) `Landroid/net/wifi/SoftApConfiguration$Builder;->setClientControlByUserEnabled(Z)Landroid/net/wifi/SoftApConfiguration$Builder;,sdk,system-api,test-api`
|
||||
* (since API 30) `Landroid/net/wifi/SoftApConfiguration$Builder;->setHiddenSsid(Z)Landroid/net/wifi/SoftApConfiguration$Builder;,sdk,system-api,test-api`
|
||||
* (since API 31) `Landroid/net/wifi/SoftApConfiguration$Builder;->setIeee80211axEnabled(Z)Landroid/net/wifi/SoftApConfiguration$Builder;,sdk,system-api,test-api`
|
||||
* (since API 33) `Landroid/net/wifi/SoftApConfiguration$Builder;->setIeee80211beEnabled(Z)Landroid/net/wifi/SoftApConfiguration$Builder;,sdk,system-api,test-api`
|
||||
* (since API 31) `Landroid/net/wifi/SoftApConfiguration$Builder;->setMacRandomizationSetting(I)Landroid/net/wifi/SoftApConfiguration$Builder;,sdk,system-api,test-api`
|
||||
* (since API 33) `Landroid/net/wifi/SoftApConfiguration$Builder;->setMaxChannelBandwidth(I)Landroid/net/wifi/SoftApConfiguration$Builder;,sdk,system-api,test-api`
|
||||
* (since API 30) `Landroid/net/wifi/SoftApConfiguration$Builder;->setMaxNumberOfClients(I)Landroid/net/wifi/SoftApConfiguration$Builder;,sdk,system-api,test-api`
|
||||
* (since API 30) `Landroid/net/wifi/SoftApConfiguration$Builder;->setPassphrase(Ljava/lang/String;I)Landroid/net/wifi/SoftApConfiguration$Builder;,sdk,system-api,test-api`
|
||||
* (since API 30) `Landroid/net/wifi/SoftApConfiguration$Builder;->setShutdownTimeoutMillis(J)Landroid/net/wifi/SoftApConfiguration$Builder;,sdk,system-api,test-api`
|
||||
* (since API 30) `Landroid/net/wifi/SoftApConfiguration$Builder;->setSsid(Ljava/lang/String;)Landroid/net/wifi/SoftApConfiguration$Builder;,sdk,system-api,test-api`
|
||||
* (since API 30, prior to API 33) `Landroid/net/wifi/SoftApConfiguration$Builder;->setSsid(Ljava/lang/String;)Landroid/net/wifi/SoftApConfiguration$Builder;,sdk,system-api,test-api`
|
||||
* (since API 33) `Landroid/net/wifi/SoftApConfiguration$Builder;->setVendorElements(Ljava/util/List;)Landroid/net/wifi/SoftApConfiguration$Builder;,sdk,system-api,test-api`
|
||||
* (since API 33) `Landroid/net/wifi/SoftApConfiguration$Builder;->setWifiSsid(Landroid/net/wifi/WifiSsid;)Landroid/net/wifi/SoftApConfiguration$Builder;,sdk,system-api,test-api`
|
||||
* (since API 30) `Landroid/net/wifi/SoftApConfiguration;->BAND_2GHZ:I,sdk,system-api,test-api`
|
||||
* (since API 30) `Landroid/net/wifi/SoftApConfiguration;->BAND_5GHZ:I,sdk,system-api,test-api`
|
||||
* (since API 31) `Landroid/net/wifi/SoftApConfiguration;->BAND_60GHZ:I,sdk,system-api,test-api`
|
||||
* (since API 30) `Landroid/net/wifi/SoftApConfiguration;->BAND_6GHZ:I,sdk,system-api,test-api`
|
||||
* (since API 31) `Landroid/net/wifi/SoftApConfiguration;->BAND_*:I,sdk,system-api,test-api`
|
||||
* (since API 33) `Landroid/net/wifi/SoftApConfiguration;->DEFAULT_TIMEOUT:J,sdk,system-api,test-api`
|
||||
* (since API 31) `Landroid/net/wifi/SoftApConfiguration;->RANDOMIZATION_NONE:I,sdk,system-api,test-api`
|
||||
* (since API 31) `Landroid/net/wifi/SoftApConfiguration;->RANDOMIZATION_NON_PERSISTENT:I,sdk,system-api,test-api`
|
||||
* (since API 31) `Landroid/net/wifi/SoftApConfiguration;->RANDOMIZATION_PERSISTENT:I,sdk,system-api,test-api`
|
||||
* (since API 33) `Landroid/net/wifi/SoftApConfiguration;->getAllowedAcsChannels(I)[I,sdk,system-api,test-api`
|
||||
* (since API 30) `Landroid/net/wifi/SoftApConfiguration;->getAllowedClientList()Ljava/util/List;,sdk,system-api,test-api`
|
||||
* (since API 30) `Landroid/net/wifi/SoftApConfiguration;->getBand()I,sdk,system-api,test-api`
|
||||
* (since API 30) `Landroid/net/wifi/SoftApConfiguration;->getBlockedClientList()Ljava/util/List;,sdk,system-api,test-api`
|
||||
* (since API 33) `Landroid/net/wifi/SoftApConfiguration;->getBridgedModeOpportunisticShutdownTimeoutMillis()J,sdk,system-api,test-api`
|
||||
* (since API 30) `Landroid/net/wifi/SoftApConfiguration;->getChannel()I,sdk,system-api,test-api`
|
||||
* (since API 31) `Landroid/net/wifi/SoftApConfiguration;->getChannels()Landroid/util/SparseIntArray;,sdk,system-api,test-api`
|
||||
* (since API 31) `Landroid/net/wifi/SoftApConfiguration;->getMacRandomizationSetting()I,sdk,system-api,test-api`
|
||||
* (since API 33) `Landroid/net/wifi/SoftApConfiguration;->getMaxChannelBandwidth()I,sdk,system-api,test-api`
|
||||
* (since API 30) `Landroid/net/wifi/SoftApConfiguration;->getMaxNumberOfClients()I,sdk,system-api,test-api`
|
||||
* (since API 33) `Landroid/net/wifi/SoftApConfiguration;->getPersistentRandomizedMacAddress()Landroid/net/MacAddress;,sdk,system-api,test-api`
|
||||
* (since API 30) `Landroid/net/wifi/SoftApConfiguration;->getShutdownTimeoutMillis()J,sdk,system-api,test-api`
|
||||
* (since API 33) `Landroid/net/wifi/SoftApConfiguration;->getVendorElements()Ljava/util/List;,sdk,system-api,test-api`
|
||||
* (since API 30) `Landroid/net/wifi/SoftApConfiguration;->isAutoShutdownEnabled()Z,sdk,system-api,test-api`
|
||||
* (since API 31) `Landroid/net/wifi/SoftApConfiguration;->isBridgedModeOpportunisticShutdownEnabled()Z,sdk,system-api,test-api`
|
||||
* (since API 30) `Landroid/net/wifi/SoftApConfiguration;->isClientControlByUserEnabled()Z,sdk,system-api,test-api`
|
||||
* (since API 31) `Landroid/net/wifi/SoftApConfiguration;->isIeee80211axEnabled()Z,sdk,system-api,test-api`
|
||||
* (since API 33) `Landroid/net/wifi/SoftApConfiguration;->isIeee80211beEnabled()Z,sdk,system-api,test-api`
|
||||
* (since API 31) `Landroid/net/wifi/SoftApConfiguration;->isUserConfiguration()Z,sdk,system-api,test-api`
|
||||
* (since API 30) `Landroid/net/wifi/SoftApInfo;->CHANNEL_WIDTH_*:I,sdk,system-api,test-api`
|
||||
* (since API 33) `Landroid/net/wifi/SoftApInfo;->CHANNEL_WIDTH_AUTO:I,sdk,system-api,test-api`
|
||||
* (on API 30) `Landroid/net/wifi/SoftApInfo;->CHANNEL_WIDTH_INVALID:I,sdk,system-api,test-api`
|
||||
* (since API 31) `Landroid/net/wifi/SoftApInfo;->getAutoShutdownTimeoutMillis()J,sdk,system-api,test-api`
|
||||
* (since API 30) `Landroid/net/wifi/SoftApInfo;->getBandwidth()I,sdk,system-api,test-api`
|
||||
* (since API 31) `Landroid/net/wifi/SoftApInfo;->getBssid()Landroid/net/MacAddress;,sdk,system-api,test-api`
|
||||
* (since API 30) `Landroid/net/wifi/SoftApInfo;->getBandwidth()I,system-api,whitelist`
|
||||
* (since API 30) `Landroid/net/wifi/SoftApInfo;->getFrequency()I,system-api,whitelist`
|
||||
* (since API 30) `Landroid/net/wifi/SoftApInfo;->getFrequency()I,sdk,system-api,test-api`
|
||||
* (since API 31) `Landroid/net/wifi/SoftApInfo;->getWifiStandard()I,sdk,system-api,test-api`
|
||||
* (since API 30) `Landroid/net/wifi/WifiClient;->getMacAddress()Landroid/net/MacAddress;,sdk,system-api,test-api`
|
||||
* (prior to API 30) `Landroid/net/wifi/WifiConfiguration$KeyMgmt;->WPA2_PSK:I,sdk,system-api,test-api`
|
||||
@@ -292,18 +309,26 @@ Greylisted/blacklisted APIs or internal constants: (some constants are hardcoded
|
||||
* (since API 30) `Landroid/net/wifi/WifiManager$SoftApCallback;->onConnectedClientsChanged(Ljava/util/List;)V,sdk,system-api,test-api`
|
||||
* (on API 30) `Landroid/net/wifi/WifiManager$SoftApCallback;->onInfoChanged(Landroid/net/wifi/SoftApInfo;)V,sdk,system-api,test-api`
|
||||
* (since API 31) `Landroid/net/wifi/WifiManager$SoftApCallback;->onInfoChanged(Ljava/util/List;)V,sdk,system-api,test-api`
|
||||
* (since API 28) `Landroid/net/wifi/WifiManager$SoftApCallback;->onStateChanged(II)V,sdk,system-api,test-api`
|
||||
* `Landroid/net/wifi/WifiManager$SoftApCallback;->onStateChanged(II)V,sdk,system-api,test-api`
|
||||
* `Landroid/net/wifi/WifiManager;->EXTRA_WIFI_AP_FAILURE_REASON:Ljava/lang/String;,sdk,system-api,test-api`
|
||||
* `Landroid/net/wifi/WifiManager;->EXTRA_WIFI_AP_INTERFACE_NAME:Ljava/lang/String;,sdk,system-api,test-api`
|
||||
* `Landroid/net/wifi/WifiManager;->EXTRA_WIFI_AP_STATE:Ljava/lang/String;,sdk,system-api,test-api`
|
||||
* (since API 30) `Landroid/net/wifi/WifiManager;->SAP_CLIENT_BLOCK_REASON_CODE_*:I,sdk,system-api,test-api`
|
||||
* (since API 28) `Landroid/net/wifi/WifiManager;->SAP_START_FAILURE_*:I,sdk,system-api,test-api`
|
||||
* (since API 28) `Landroid/net/wifi/WifiManager;->WIFI_AP_STATE_FAILED:I,sdk,system-api,test-api`
|
||||
* `Landroid/net/wifi/WifiManager;->SAP_START_FAILURE_*:I,sdk,system-api,test-api`
|
||||
* `Landroid/net/wifi/WifiManager;->WIFI_AP_STATE_CHANGED_ACTION:Ljava/lang/String;,sdk,system-api,test-api`
|
||||
* `Landroid/net/wifi/WifiManager;->WIFI_AP_STATE_DISABLED:I,sdk,system-api,test-api`
|
||||
* `Landroid/net/wifi/WifiManager;->WIFI_AP_STATE_DISABLING:I,sdk,system-api,test-api`
|
||||
* `Landroid/net/wifi/WifiManager;->WIFI_AP_STATE_ENABLED:I,sdk,system-api,test-api`
|
||||
* `Landroid/net/wifi/WifiManager;->WIFI_AP_STATE_ENABLING:I,sdk,system-api,test-api`
|
||||
* `Landroid/net/wifi/WifiManager;->WIFI_AP_STATE_FAILED:I,sdk,system-api,test-api`
|
||||
* (since API 30) `Landroid/net/wifi/WifiManager;->getSoftApConfiguration()Landroid/net/wifi/SoftApConfiguration;,sdk,system-api,test-api`
|
||||
* (prior to API 30) `Landroid/net/wifi/WifiManager;->getWifiApConfiguration()Landroid/net/wifi/WifiConfiguration;,sdk,system-api,test-api`
|
||||
* (since API 30) `Landroid/net/wifi/WifiManager;->isApMacRandomizationSupported()Z,sdk,system-api,test-api`
|
||||
* (since API 28) `Landroid/net/wifi/WifiManager;->registerSoftApCallback(Ljava/util/concurrent/Executor;Landroid/net/wifi/WifiManager$SoftApCallback;)V,sdk,system-api,test-api`
|
||||
* `Landroid/net/wifi/WifiManager;->registerSoftApCallback(Ljava/util/concurrent/Executor;Landroid/net/wifi/WifiManager$SoftApCallback;)V,sdk,system-api,test-api`
|
||||
* (since API 30) `Landroid/net/wifi/WifiManager;->setSoftApConfiguration(Landroid/net/wifi/SoftApConfiguration;)Z,sdk,system-api,test-api`
|
||||
* (prior to API 30) `Landroid/net/wifi/WifiManager;->setWifiApConfiguration(Landroid/net/wifi/WifiConfiguration;)Z,sdk,system-api,test-api`
|
||||
* (since API 30) `Landroid/net/wifi/WifiManager;->startLocalOnlyHotspot(Landroid/net/wifi/SoftApConfiguration;Ljava/util/concurrent/Executor;Landroid/net/wifi/WifiManager$LocalOnlyHotspotCallback;)V,sdk,system-api,test-api`
|
||||
* (since API 28) `Landroid/net/wifi/WifiManager;->unregisterSoftApCallback(Landroid/net/wifi/WifiManager$SoftApCallback;)V,sdk,system-api,test-api`
|
||||
* `Landroid/net/wifi/WifiManager;->unregisterSoftApCallback(Landroid/net/wifi/WifiManager$SoftApCallback;)V,sdk,system-api,test-api`
|
||||
* `Landroid/net/wifi/p2p/WifiP2pGroupList;->getGroupList()Ljava/util/List;,sdk,system-api,test-api`
|
||||
* `Landroid/net/wifi/p2p/WifiP2pManager$PersistentGroupInfoListener;->onPersistentGroupInfoAvailable(Landroid/net/wifi/p2p/WifiP2pGroupList;)V,sdk,system-api,test-api`
|
||||
* `Landroid/net/wifi/p2p/WifiP2pManager;->deletePersistentGroup(Landroid/net/wifi/p2p/WifiP2pManager$Channel;ILandroid/net/wifi/p2p/WifiP2pManager$ActionListener;)V,sdk,system-api,test-api`
|
||||
@@ -322,20 +347,20 @@ Nonexported system resources:
|
||||
* (since API 30) `@com.android.networkstack.tethering:array/config_tether_wifi_regexs`
|
||||
* (since API 30) `@com.android.networkstack.tethering:array/config_tether_wigig_regexs`
|
||||
* (since API 30) `@com.android.wifi.resources:bool/config_wifi_p2p_mac_randomization_supported`
|
||||
* (since API 31) `@com.android.wifi.resources:integer/config_wifiFrameworkSoftApShutDownIdleInstanceInBridgedModeTimeoutMillisecond`
|
||||
* (since API 30) `@com.android.wifi.resources:integer/config_wifiFrameworkSoftApShutDownTimeoutMilliseconds`
|
||||
|
||||
Other: 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;
|
||||
(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`, `ip6tables-save`;
|
||||
* `iptables-save`, `ip6tables-save`;
|
||||
* `echo`;
|
||||
* `/system/bin/ip` (`monitor neigh rule unreachable`);
|
||||
* `ndc` (`ipfwd` since API 23, `nat` since API 28);
|
||||
* `ndc` (`ipfwd nat`);
|
||||
* `iptables`, `ip6tables` (with correct version corresponding to API level, `-nvx -L <chain>`);
|
||||
* `sh`;
|
||||
* `su`.
|
||||
|
||||
@@ -1,31 +1,13 @@
|
||||
plugins {
|
||||
id("com.github.ben-manes.versions") version "0.39.0"
|
||||
id("com.android.application") version "8.0.0-beta04" apply false
|
||||
id("com.github.ben-manes.versions") version "0.46.0"
|
||||
id("org.jetbrains.kotlin.android") version "1.8.10" apply false
|
||||
}
|
||||
|
||||
buildscript {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
}
|
||||
|
||||
dependencies {
|
||||
classpath(kotlin("gradle-plugin", "1.5.10"))
|
||||
classpath("com.android.tools.build:gradle:7.0.0-beta03")
|
||||
classpath("com.google.firebase:firebase-crashlytics-gradle:2.6.1")
|
||||
classpath("com.google.android.gms:oss-licenses-plugin:0.10.4")
|
||||
classpath("com.google.gms:google-services:4.3.8")
|
||||
classpath("com.google.firebase:firebase-crashlytics-gradle:2.9.4")
|
||||
classpath("com.google.android.gms:oss-licenses-plugin:0.10.6")
|
||||
classpath("com.google.gms:google-services:4.3.15")
|
||||
}
|
||||
}
|
||||
|
||||
allprojects {
|
||||
repositories {
|
||||
google()
|
||||
jcenter()
|
||||
mavenCentral()
|
||||
maven("https://jitpack.io")
|
||||
}
|
||||
}
|
||||
|
||||
tasks.register<Delete>("clean") {
|
||||
delete(rootProject.buildDir)
|
||||
}
|
||||
|
||||
153
detekt.yml
153
detekt.yml
@@ -1,4 +1,4 @@
|
||||
# https://github.com/detekt/detekt/blob/v1.14.2/detekt-core/src/main/resources/default-detekt-config.yml
|
||||
# https://github.com/detekt/detekt/blob/v1.19.0/detekt-core/src/main/resources/default-detekt-config.yml
|
||||
|
||||
comments:
|
||||
active: false
|
||||
@@ -19,6 +19,16 @@ complexity:
|
||||
ignoreSingleWhenExpression: false
|
||||
ignoreSimpleWhenEntries: false
|
||||
ignoreNestingFunctions: false
|
||||
nestingFunctions:
|
||||
- 'also'
|
||||
- 'apply'
|
||||
- 'forEach'
|
||||
- 'isNotNull'
|
||||
- 'ifNull'
|
||||
- 'let'
|
||||
- 'run'
|
||||
- 'use'
|
||||
- 'with'
|
||||
LabeledExpression:
|
||||
active: false
|
||||
LargeClass:
|
||||
@@ -33,9 +43,11 @@ complexity:
|
||||
constructorThreshold: 7
|
||||
ignoreDefaultParameters: true
|
||||
ignoreDataClasses: true
|
||||
ignoreAnnotated: []
|
||||
ignoreAnnotatedParameter: []
|
||||
MethodOverloading:
|
||||
active: false
|
||||
NamedArguments:
|
||||
active: false
|
||||
NestedBlockDepth:
|
||||
active: true
|
||||
threshold: 4
|
||||
@@ -64,8 +76,12 @@ coroutines:
|
||||
active: true
|
||||
GlobalCoroutineUsage:
|
||||
active: false
|
||||
InjectDispatcher:
|
||||
active: false
|
||||
RedundantSuspendModifier:
|
||||
active: true
|
||||
SleepInsteadOfDelay:
|
||||
active: true
|
||||
SuspendFunWithFlowReturnType:
|
||||
active: true
|
||||
|
||||
@@ -108,13 +124,19 @@ exceptions:
|
||||
active: true
|
||||
ExceptionRaisedInUnexpectedLocation:
|
||||
active: true
|
||||
methodNames: [toString, hashCode, equals, finalize]
|
||||
methodNames:
|
||||
- 'equals'
|
||||
- 'finalize'
|
||||
- 'hashCode'
|
||||
- 'toString'
|
||||
InstanceOfCheckForException:
|
||||
active: false
|
||||
NotImplementedDeclaration:
|
||||
active: true
|
||||
ObjectExtendsThrowable:
|
||||
active: true
|
||||
PrintStackTrace:
|
||||
active: false
|
||||
active: true
|
||||
RethrowCaughtException:
|
||||
active: false
|
||||
ReturnFromFinally:
|
||||
@@ -123,10 +145,10 @@ exceptions:
|
||||
SwallowedException:
|
||||
active: true
|
||||
ignoredExceptionTypes:
|
||||
- InterruptedException
|
||||
- NumberFormatException
|
||||
- ParseException
|
||||
- MalformedURLException
|
||||
- 'InterruptedException'
|
||||
- 'MalformedURLException'
|
||||
- 'NumberFormatException'
|
||||
- 'ParseException'
|
||||
allowedExceptionNameRegex: '_|(ignore|expected).*'
|
||||
ThrowingExceptionFromFinally:
|
||||
active: false
|
||||
@@ -136,9 +158,15 @@ exceptions:
|
||||
active: true
|
||||
excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**']
|
||||
exceptions:
|
||||
- IllegalArgumentException
|
||||
- IllegalStateException
|
||||
- IOException
|
||||
- 'ArrayIndexOutOfBoundsException'
|
||||
- 'Exception'
|
||||
- 'IllegalArgumentException'
|
||||
- 'IllegalMonitorStateException'
|
||||
- 'IllegalStateException'
|
||||
- 'IndexOutOfBoundsException'
|
||||
- 'NullPointerException'
|
||||
- 'RuntimeException'
|
||||
- 'Throwable'
|
||||
ThrowingNewInstanceOfSameException:
|
||||
active: true
|
||||
TooGenericExceptionCaught:
|
||||
@@ -146,10 +174,10 @@ exceptions:
|
||||
TooGenericExceptionThrown:
|
||||
active: true
|
||||
exceptionNames:
|
||||
- Error
|
||||
- Exception
|
||||
- Throwable
|
||||
- RuntimeException
|
||||
- 'Error'
|
||||
- 'Exception'
|
||||
- 'RuntimeException'
|
||||
- 'Throwable'
|
||||
|
||||
formatting:
|
||||
active: true
|
||||
@@ -179,11 +207,13 @@ formatting:
|
||||
ImportOrdering:
|
||||
active: true
|
||||
autoCorrect: true
|
||||
layout: 'idea'
|
||||
layout: '*,java.**,javax.**,kotlin.**,^'
|
||||
Indentation:
|
||||
active: false
|
||||
MaximumLineLength:
|
||||
active: false
|
||||
active: true
|
||||
maxLineLength: 120
|
||||
ignoreBackTickedIdentifier: false
|
||||
ModifierOrdering:
|
||||
active: true
|
||||
autoCorrect: true
|
||||
@@ -229,6 +259,9 @@ formatting:
|
||||
autoCorrect: true
|
||||
ParameterListWrapping:
|
||||
active: false
|
||||
SpacingAroundAngleBrackets:
|
||||
active: true
|
||||
autoCorrect: true
|
||||
SpacingAroundColon:
|
||||
active: true
|
||||
autoCorrect: true
|
||||
@@ -256,6 +289,9 @@ formatting:
|
||||
SpacingAroundRangeOperator:
|
||||
active: true
|
||||
autoCorrect: true
|
||||
SpacingAroundUnaryOperator:
|
||||
active: true
|
||||
autoCorrect: true
|
||||
SpacingBetweenDeclarationsWithAnnotations:
|
||||
active: false
|
||||
SpacingBetweenDeclarationsWithComments:
|
||||
@@ -266,6 +302,8 @@ formatting:
|
||||
|
||||
naming:
|
||||
active: true
|
||||
BooleanPropertyNaming:
|
||||
active: false
|
||||
ClassNaming:
|
||||
active: true
|
||||
excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**']
|
||||
@@ -297,7 +335,6 @@ naming:
|
||||
functionPattern: '([a-z][a-zA-Z0-9]*)|(`.*`)'
|
||||
excludeClassPattern: '$^'
|
||||
ignoreOverridden: true
|
||||
ignoreAnnotated: ['Composable']
|
||||
FunctionParameterNaming:
|
||||
active: true
|
||||
excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**']
|
||||
@@ -307,11 +344,17 @@ naming:
|
||||
InvalidPackageDeclaration:
|
||||
active: true
|
||||
rootPackage: ''
|
||||
LambdaParameterNaming:
|
||||
active: true
|
||||
excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**']
|
||||
parameterPattern: '[a-z][A-Za-z0-9]*|_'
|
||||
MatchingDeclarationName:
|
||||
active: true
|
||||
mustBeFirst: true
|
||||
MemberNameEqualsClassName:
|
||||
active: false
|
||||
NoNameShadowing:
|
||||
active: true
|
||||
NonBooleanPropertyPrefixedWithIs:
|
||||
active: true
|
||||
excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**']
|
||||
@@ -327,7 +370,7 @@ naming:
|
||||
packagePattern: '[a-z]+(\.[a-z][A-Za-z0-9]*)*'
|
||||
TopLevelPropertyNaming:
|
||||
active: true
|
||||
excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**']
|
||||
excludes: ['buildSrc/**', '**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**']
|
||||
constantPattern: '[A-Z][_A-Z0-9]*'
|
||||
propertyPattern: '[A-Za-z][_A-Za-z0-9]*'
|
||||
privatePropertyPattern: '_?[A-Za-z][_A-Za-z0-9]*'
|
||||
@@ -359,14 +402,26 @@ performance:
|
||||
|
||||
potential-bugs:
|
||||
active: true
|
||||
AvoidReferentialEquality:
|
||||
active: true
|
||||
forbiddenTypePatterns:
|
||||
- 'kotlin.String'
|
||||
CastToNullableType:
|
||||
active: false
|
||||
Deprecation:
|
||||
active: true
|
||||
DontDowncastCollectionTypes:
|
||||
active: true
|
||||
DoubleMutabilityForCollection:
|
||||
active: true
|
||||
DuplicateCaseInWhenExpression:
|
||||
active: true
|
||||
EqualsAlwaysReturnsTrueOrFalse:
|
||||
active: true
|
||||
EqualsWithHashCodeExist:
|
||||
active: true
|
||||
ExitOutsideMain:
|
||||
active: true
|
||||
ExplicitGarbageCollectionCall:
|
||||
active: true
|
||||
HasPlatformType:
|
||||
@@ -374,7 +429,11 @@ potential-bugs:
|
||||
IgnoredReturnValue:
|
||||
active: true
|
||||
restrictToAnnotatedMethods: true
|
||||
returnValueAnnotations: ['*.CheckReturnValue', '*.CheckResult']
|
||||
returnValueAnnotations:
|
||||
- '*.CheckResult'
|
||||
- '*.CheckReturnValue'
|
||||
ignoreReturnValueAnnotations:
|
||||
- '*.CanIgnoreReturnValue'
|
||||
ImplicitDefaultLocale:
|
||||
active: true
|
||||
ImplicitUnitReturnType:
|
||||
@@ -389,6 +448,9 @@ potential-bugs:
|
||||
active: false
|
||||
MapGetWithNotNullAssertionOperator:
|
||||
active: true
|
||||
MissingPackageDeclaration:
|
||||
active: true
|
||||
excludes: ['buildSrc/**', '**/*.kts']
|
||||
MissingWhenCase:
|
||||
active: false
|
||||
NullableToStringCall:
|
||||
@@ -398,15 +460,20 @@ potential-bugs:
|
||||
UnconditionalJumpStatementInLoop:
|
||||
active: true
|
||||
UnnecessaryNotNullOperator:
|
||||
active: false
|
||||
active: true
|
||||
UnnecessarySafeCall:
|
||||
active: false
|
||||
active: true
|
||||
UnreachableCatchBlock:
|
||||
active: true
|
||||
UnreachableCode:
|
||||
active: true
|
||||
UnsafeCallOnNullableType:
|
||||
active: true
|
||||
excludes: ['**/test/**', '**/androidTest/**', '**/commonTest/**', '**/jvmTest/**', '**/jsTest/**', '**/iosTest/**']
|
||||
UnsafeCast:
|
||||
active: false
|
||||
active: true
|
||||
UnusedUnaryOperator:
|
||||
active: true
|
||||
UselessPostfixExpression:
|
||||
active: true
|
||||
WrongEqualsTypeParameter:
|
||||
@@ -422,6 +489,8 @@ style:
|
||||
active: false
|
||||
DataClassShouldBeImmutable:
|
||||
active: false
|
||||
DestructuringDeclarationWithTooManyEntries:
|
||||
active: false
|
||||
EqualsNullCall:
|
||||
active: true
|
||||
EqualsOnSignatureLine:
|
||||
@@ -435,18 +504,27 @@ style:
|
||||
includeLineWrapping: false
|
||||
ForbiddenComment:
|
||||
active: true
|
||||
values: ['TODO:', 'FIXME:', 'STOPSHIP:']
|
||||
values:
|
||||
- 'FIXME:'
|
||||
- 'STOPSHIP:'
|
||||
- 'TODO:'
|
||||
allowedPatterns: ''
|
||||
customMessage: ''
|
||||
ForbiddenImport:
|
||||
active: true
|
||||
imports: []
|
||||
forbiddenPatterns: ''
|
||||
ForbiddenMethodCall:
|
||||
active: true
|
||||
methods: ['kotlin.io.println', 'kotlin.io.print']
|
||||
methods:
|
||||
- 'kotlin.io.print'
|
||||
- 'kotlin.io.println'
|
||||
ForbiddenPublicDataClass:
|
||||
active: true
|
||||
ignorePackages: ['*.internal', '*.internal.*']
|
||||
excludes: ['**']
|
||||
ignorePackages:
|
||||
- '*.internal'
|
||||
- '*.internal.*'
|
||||
ForbiddenVoid:
|
||||
active: true
|
||||
ignoreOverridden: true
|
||||
@@ -454,12 +532,14 @@ style:
|
||||
FunctionOnlyReturningConstant:
|
||||
active: true
|
||||
ignoreOverridableFunction: true
|
||||
excludedFunctions: 'describeContents'
|
||||
excludeAnnotatedFunction: ['dagger.Provides']
|
||||
ignoreActualFunction: true
|
||||
excludedFunctions: ''
|
||||
LibraryCodeMustSpecifyReturnType:
|
||||
active: true
|
||||
excludes: ['**']
|
||||
LibraryEntitiesShouldNotBePublic:
|
||||
active: true
|
||||
excludes: ['**']
|
||||
LoopWithTooManyJumpStatements:
|
||||
active: true
|
||||
maxJumpCount: 1
|
||||
@@ -479,12 +559,16 @@ style:
|
||||
active: true
|
||||
ModifierOrder:
|
||||
active: true
|
||||
MultilineLambdaItParameter:
|
||||
active: false
|
||||
NestedClassesVisibility:
|
||||
active: true
|
||||
NewLineAtEndOfFile:
|
||||
active: true
|
||||
NoTabs:
|
||||
active: true
|
||||
ObjectLiteralToLambda:
|
||||
active: true
|
||||
OptionalAbstractKeyword:
|
||||
active: true
|
||||
OptionalUnit:
|
||||
@@ -497,6 +581,8 @@ style:
|
||||
active: true
|
||||
RedundantExplicitType:
|
||||
active: true
|
||||
RedundantHigherOrderMapUsage:
|
||||
active: true
|
||||
RedundantVisibilityModifierRule:
|
||||
active: true
|
||||
ReturnCount:
|
||||
@@ -510,6 +596,7 @@ style:
|
||||
ThrowsCount:
|
||||
active: true
|
||||
max: 2
|
||||
excludeGuardClauses: false
|
||||
TrailingWhitespace:
|
||||
active: true
|
||||
UnderscoresInNumericLiterals:
|
||||
@@ -520,6 +607,8 @@ style:
|
||||
active: true
|
||||
UnnecessaryApply:
|
||||
active: true
|
||||
UnnecessaryFilter:
|
||||
active: true
|
||||
UnnecessaryInheritance:
|
||||
active: true
|
||||
UnnecessaryLet:
|
||||
@@ -535,6 +624,8 @@ style:
|
||||
UnusedPrivateMember:
|
||||
active: true
|
||||
allowedNames: '(_|ignored|expected|serialVersionUID)'
|
||||
UseAnyOrNoneInsteadOfFind:
|
||||
active: true
|
||||
UseArrayLiteralsInAnnotations:
|
||||
active: true
|
||||
UseCheckNotNull:
|
||||
@@ -545,8 +636,14 @@ style:
|
||||
active: false
|
||||
UseEmptyCounterpart:
|
||||
active: true
|
||||
UseIfEmptyOrIfBlank:
|
||||
active: true
|
||||
UseIfInsteadOfWhen:
|
||||
active: false
|
||||
UseIsNullOrEmpty:
|
||||
active: true
|
||||
UseOrEmpty:
|
||||
active: true
|
||||
UseRequire:
|
||||
active: true
|
||||
UseRequireNotNull:
|
||||
|
||||
@@ -13,6 +13,7 @@ android.databinding.incremental=true
|
||||
android.enableJetifier=true
|
||||
android.enableR8.fullMode=true
|
||||
android.enableResourceOptimizations=false
|
||||
android.nonTransitiveRClass=true
|
||||
android.useAndroidX=true
|
||||
kapt.incremental.apt=true
|
||||
org.gradle.jvmargs=-Xmx1536m
|
||||
|
||||
BIN
gradle/wrapper/gradle-wrapper.jar
vendored
BIN
gradle/wrapper/gradle-wrapper.jar
vendored
Binary file not shown.
3
gradle/wrapper/gradle-wrapper.properties
vendored
3
gradle/wrapper/gradle-wrapper.properties
vendored
@@ -1,5 +1,6 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-7.0.2-bin.zip
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.0.1-bin.zip
|
||||
networkTimeout=10000
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
zipStorePath=wrapper/dists
|
||||
|
||||
285
gradlew
vendored
285
gradlew
vendored
@@ -1,7 +1,7 @@
|
||||
#!/usr/bin/env sh
|
||||
#!/bin/sh
|
||||
|
||||
#
|
||||
# Copyright 2015 the original author or authors.
|
||||
# Copyright © 2015-2021 the original authors.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
@@ -17,67 +17,101 @@
|
||||
#
|
||||
|
||||
##############################################################################
|
||||
##
|
||||
## Gradle start up script for UN*X
|
||||
##
|
||||
#
|
||||
# Gradle start up script for POSIX generated by Gradle.
|
||||
#
|
||||
# Important for running:
|
||||
#
|
||||
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
|
||||
# noncompliant, but you have some other compliant shell such as ksh or
|
||||
# bash, then to run this script, type that shell name before the whole
|
||||
# command line, like:
|
||||
#
|
||||
# ksh Gradle
|
||||
#
|
||||
# Busybox and similar reduced shells will NOT work, because this script
|
||||
# requires all of these POSIX shell features:
|
||||
# * functions;
|
||||
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
|
||||
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
|
||||
# * compound commands having a testable exit status, especially «case»;
|
||||
# * various built-in commands including «command», «set», and «ulimit».
|
||||
#
|
||||
# Important for patching:
|
||||
#
|
||||
# (2) This script targets any POSIX shell, so it avoids extensions provided
|
||||
# by Bash, Ksh, etc; in particular arrays are avoided.
|
||||
#
|
||||
# The "traditional" practice of packing multiple parameters into a
|
||||
# space-separated string is a well documented source of bugs and security
|
||||
# problems, so this is (mostly) avoided, by progressively accumulating
|
||||
# options in "$@", and eventually passing that to Java.
|
||||
#
|
||||
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
|
||||
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
|
||||
# see the in-line comments for details.
|
||||
#
|
||||
# There are tweaks for specific operating systems such as AIX, CygWin,
|
||||
# Darwin, MinGW, and NonStop.
|
||||
#
|
||||
# (3) This script is generated from the Groovy template
|
||||
# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
|
||||
# within the Gradle project.
|
||||
#
|
||||
# You can find Gradle at https://github.com/gradle/gradle/.
|
||||
#
|
||||
##############################################################################
|
||||
|
||||
# Attempt to set APP_HOME
|
||||
# Resolve links: $0 may be a link
|
||||
PRG="$0"
|
||||
# Need this for relative symlinks.
|
||||
while [ -h "$PRG" ] ; do
|
||||
ls=`ls -ld "$PRG"`
|
||||
link=`expr "$ls" : '.*-> \(.*\)$'`
|
||||
if expr "$link" : '/.*' > /dev/null; then
|
||||
PRG="$link"
|
||||
else
|
||||
PRG=`dirname "$PRG"`"/$link"
|
||||
fi
|
||||
done
|
||||
SAVED="`pwd`"
|
||||
cd "`dirname \"$PRG\"`/" >/dev/null
|
||||
APP_HOME="`pwd -P`"
|
||||
cd "$SAVED" >/dev/null
|
||||
|
||||
APP_NAME="Gradle"
|
||||
APP_BASE_NAME=`basename "$0"`
|
||||
# Resolve links: $0 may be a link
|
||||
app_path=$0
|
||||
|
||||
# Need this for daisy-chained symlinks.
|
||||
while
|
||||
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
|
||||
[ -h "$app_path" ]
|
||||
do
|
||||
ls=$( ls -ld "$app_path" )
|
||||
link=${ls#*' -> '}
|
||||
case $link in #(
|
||||
/*) app_path=$link ;; #(
|
||||
*) app_path=$APP_HOME$link ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# This is normally unused
|
||||
# shellcheck disable=SC2034
|
||||
APP_BASE_NAME=${0##*/}
|
||||
APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit
|
||||
|
||||
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
|
||||
|
||||
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||
MAX_FD="maximum"
|
||||
MAX_FD=maximum
|
||||
|
||||
warn () {
|
||||
echo "$*"
|
||||
}
|
||||
} >&2
|
||||
|
||||
die () {
|
||||
echo
|
||||
echo "$*"
|
||||
echo
|
||||
exit 1
|
||||
}
|
||||
} >&2
|
||||
|
||||
# OS specific support (must be 'true' or 'false').
|
||||
cygwin=false
|
||||
msys=false
|
||||
darwin=false
|
||||
nonstop=false
|
||||
case "`uname`" in
|
||||
CYGWIN* )
|
||||
cygwin=true
|
||||
;;
|
||||
Darwin* )
|
||||
darwin=true
|
||||
;;
|
||||
MINGW* )
|
||||
msys=true
|
||||
;;
|
||||
NONSTOP* )
|
||||
nonstop=true
|
||||
;;
|
||||
case "$( uname )" in #(
|
||||
CYGWIN* ) cygwin=true ;; #(
|
||||
Darwin* ) darwin=true ;; #(
|
||||
MSYS* | MINGW* ) msys=true ;; #(
|
||||
NONSTOP* ) nonstop=true ;;
|
||||
esac
|
||||
|
||||
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
|
||||
@@ -87,9 +121,9 @@ CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
|
||||
if [ -n "$JAVA_HOME" ] ; then
|
||||
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||
# IBM's JDK on AIX uses strange locations for the executables
|
||||
JAVACMD="$JAVA_HOME/jre/sh/java"
|
||||
JAVACMD=$JAVA_HOME/jre/sh/java
|
||||
else
|
||||
JAVACMD="$JAVA_HOME/bin/java"
|
||||
JAVACMD=$JAVA_HOME/bin/java
|
||||
fi
|
||||
if [ ! -x "$JAVACMD" ] ; then
|
||||
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||
@@ -98,7 +132,7 @@ Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
else
|
||||
JAVACMD="java"
|
||||
JAVACMD=java
|
||||
which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
@@ -106,80 +140,105 @@ location of your Java installation."
|
||||
fi
|
||||
|
||||
# Increase the maximum file descriptors if we can.
|
||||
if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then
|
||||
MAX_FD_LIMIT=`ulimit -H -n`
|
||||
if [ $? -eq 0 ] ; then
|
||||
if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
|
||||
MAX_FD="$MAX_FD_LIMIT"
|
||||
fi
|
||||
ulimit -n $MAX_FD
|
||||
if [ $? -ne 0 ] ; then
|
||||
warn "Could not set maximum file descriptor limit: $MAX_FD"
|
||||
fi
|
||||
else
|
||||
warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
|
||||
fi
|
||||
fi
|
||||
|
||||
# For Darwin, add options to specify how the application appears in the dock
|
||||
if $darwin; then
|
||||
GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
|
||||
fi
|
||||
|
||||
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||
if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then
|
||||
APP_HOME=`cygpath --path --mixed "$APP_HOME"`
|
||||
CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
|
||||
|
||||
JAVACMD=`cygpath --unix "$JAVACMD"`
|
||||
|
||||
# We build the pattern for arguments to be converted via cygpath
|
||||
ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
|
||||
SEP=""
|
||||
for dir in $ROOTDIRSRAW ; do
|
||||
ROOTDIRS="$ROOTDIRS$SEP$dir"
|
||||
SEP="|"
|
||||
done
|
||||
OURCYGPATTERN="(^($ROOTDIRS))"
|
||||
# Add a user-defined pattern to the cygpath arguments
|
||||
if [ "$GRADLE_CYGPATTERN" != "" ] ; then
|
||||
OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
|
||||
fi
|
||||
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||
i=0
|
||||
for arg in "$@" ; do
|
||||
CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -`
|
||||
CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option
|
||||
|
||||
if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
|
||||
eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
|
||||
else
|
||||
eval `echo args$i`="\"$arg\""
|
||||
fi
|
||||
i=`expr $i + 1`
|
||||
done
|
||||
case $i in
|
||||
0) set -- ;;
|
||||
1) set -- "$args0" ;;
|
||||
2) set -- "$args0" "$args1" ;;
|
||||
3) set -- "$args0" "$args1" "$args2" ;;
|
||||
4) set -- "$args0" "$args1" "$args2" "$args3" ;;
|
||||
5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
|
||||
6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
|
||||
7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
|
||||
8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
|
||||
9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
|
||||
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
|
||||
case $MAX_FD in #(
|
||||
max*)
|
||||
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC3045
|
||||
MAX_FD=$( ulimit -H -n ) ||
|
||||
warn "Could not query maximum file descriptor limit"
|
||||
esac
|
||||
case $MAX_FD in #(
|
||||
'' | soft) :;; #(
|
||||
*)
|
||||
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
|
||||
# shellcheck disable=SC3045
|
||||
ulimit -n "$MAX_FD" ||
|
||||
warn "Could not set maximum file descriptor limit to $MAX_FD"
|
||||
esac
|
||||
fi
|
||||
|
||||
# Escape application args
|
||||
save () {
|
||||
for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done
|
||||
echo " "
|
||||
}
|
||||
APP_ARGS=`save "$@"`
|
||||
# Collect all arguments for the java command, stacking in reverse order:
|
||||
# * args from the command line
|
||||
# * the main class name
|
||||
# * -classpath
|
||||
# * -D...appname settings
|
||||
# * --module-path (only if needed)
|
||||
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
|
||||
|
||||
# Collect all arguments for the java command, following the shell quoting and substitution rules
|
||||
eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS"
|
||||
# For Cygwin or MSYS, switch paths to Windows format before running java
|
||||
if "$cygwin" || "$msys" ; then
|
||||
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
|
||||
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
|
||||
|
||||
JAVACMD=$( cygpath --unix "$JAVACMD" )
|
||||
|
||||
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||
for arg do
|
||||
if
|
||||
case $arg in #(
|
||||
-*) false ;; # don't mess with options #(
|
||||
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
|
||||
[ -e "$t" ] ;; #(
|
||||
*) false ;;
|
||||
esac
|
||||
then
|
||||
arg=$( cygpath --path --ignore --mixed "$arg" )
|
||||
fi
|
||||
# Roll the args list around exactly as many times as the number of
|
||||
# args, so each arg winds up back in the position where it started, but
|
||||
# possibly modified.
|
||||
#
|
||||
# NB: a `for` loop captures its iteration list before it begins, so
|
||||
# changing the positional parameters here affects neither the number of
|
||||
# iterations, nor the values presented in `arg`.
|
||||
shift # remove old arg
|
||||
set -- "$@" "$arg" # push replacement arg
|
||||
done
|
||||
fi
|
||||
|
||||
# Collect all arguments for the java command;
|
||||
# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of
|
||||
# shell script including quotes and variable substitutions, so put them in
|
||||
# double quotes to make sure that they get re-expanded; and
|
||||
# * put everything else in single quotes, so that it's not re-expanded.
|
||||
|
||||
set -- \
|
||||
"-Dorg.gradle.appname=$APP_BASE_NAME" \
|
||||
-classpath "$CLASSPATH" \
|
||||
org.gradle.wrapper.GradleWrapperMain \
|
||||
"$@"
|
||||
|
||||
# Stop when "xargs" is not available.
|
||||
if ! command -v xargs >/dev/null 2>&1
|
||||
then
|
||||
die "xargs is not available"
|
||||
fi
|
||||
|
||||
# Use "xargs" to parse quoted args.
|
||||
#
|
||||
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
|
||||
#
|
||||
# In Bash we could simply go:
|
||||
#
|
||||
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
|
||||
# set -- "${ARGS[@]}" "$@"
|
||||
#
|
||||
# but POSIX shell has neither arrays nor command substitution, so instead we
|
||||
# post-process each arg (as a line of input to sed) to backslash-escape any
|
||||
# character that might be a shell metacharacter, then use eval to reverse
|
||||
# that process (while maintaining the separation between arguments), and wrap
|
||||
# the whole thing up as a single "set" statement.
|
||||
#
|
||||
# This will of course break if any of these variables contains a newline or
|
||||
# an unmatched quote.
|
||||
#
|
||||
|
||||
eval "set -- $(
|
||||
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
|
||||
xargs -n1 |
|
||||
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
|
||||
tr '\n' ' '
|
||||
)" '"$@"'
|
||||
|
||||
exec "$JAVACMD" "$@"
|
||||
|
||||
11
gradlew.bat
vendored
11
gradlew.bat
vendored
@@ -26,6 +26,7 @@ if "%OS%"=="Windows_NT" setlocal
|
||||
|
||||
set DIRNAME=%~dp0
|
||||
if "%DIRNAME%"=="" set DIRNAME=.
|
||||
@rem This is normally unused
|
||||
set APP_BASE_NAME=%~n0
|
||||
set APP_HOME=%DIRNAME%
|
||||
|
||||
@@ -40,7 +41,7 @@ if defined JAVA_HOME goto findJavaFromJavaHome
|
||||
|
||||
set JAVA_EXE=java.exe
|
||||
%JAVA_EXE% -version >NUL 2>&1
|
||||
if "%ERRORLEVEL%" == "0" goto execute
|
||||
if %ERRORLEVEL% equ 0 goto execute
|
||||
|
||||
echo.
|
||||
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
@@ -75,13 +76,15 @@ set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
|
||||
|
||||
:end
|
||||
@rem End local scope for the variables with windows NT shell
|
||||
if "%ERRORLEVEL%"=="0" goto mainEnd
|
||||
if %ERRORLEVEL% equ 0 goto mainEnd
|
||||
|
||||
:fail
|
||||
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||
rem the _cmd.exe /c_ return code!
|
||||
if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
|
||||
exit /b 1
|
||||
set EXIT_CODE=%ERRORLEVEL%
|
||||
if %EXIT_CODE% equ 0 set EXIT_CODE=1
|
||||
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
|
||||
exit /b %EXIT_CODE%
|
||||
|
||||
:mainEnd
|
||||
if "%OS%"=="Windows_NT" endlocal
|
||||
|
||||
@@ -9,23 +9,24 @@ plugins {
|
||||
}
|
||||
|
||||
android {
|
||||
val javaVersion = JavaVersion.VERSION_1_8
|
||||
val targetSdk = 29
|
||||
buildToolsVersion = "31.0.0-rc4"
|
||||
namespace = "be.mygod.vpnhotspot"
|
||||
|
||||
val javaVersion = 11
|
||||
buildToolsVersion = "33.0.2"
|
||||
compileOptions {
|
||||
isCoreLibraryDesugaringEnabled = true
|
||||
sourceCompatibility = javaVersion
|
||||
targetCompatibility = javaVersion
|
||||
sourceCompatibility(javaVersion)
|
||||
targetCompatibility(javaVersion)
|
||||
}
|
||||
compileSdkPreview = "android-S"
|
||||
kotlinOptions.jvmTarget = javaVersion.toString()
|
||||
kotlin.jvmToolchain(javaVersion)
|
||||
compileSdk = 33
|
||||
defaultConfig {
|
||||
applicationId = "be.mygod.vpnhotspot"
|
||||
minSdk = 21
|
||||
if (targetSdk == 31) targetSdkPreview = "S" else this.targetSdk = targetSdk
|
||||
resourceConfigurations.addAll(arrayOf("it", "ru", "zh-rCN", "zh-rTW"))
|
||||
versionCode = 260
|
||||
versionName = "2.11.7"
|
||||
minSdk = 28
|
||||
targetSdk = 33
|
||||
resourceConfigurations.addAll(arrayOf("it", "pt-rBR", "ru", "zh-rCN", "zh-rTW"))
|
||||
versionCode = 1000
|
||||
versionName = "2.16.0"
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
javaCompileOptions.annotationProcessorOptions.arguments.apply {
|
||||
put("room.expandProjection", "true")
|
||||
@@ -33,9 +34,9 @@ android {
|
||||
put("room.schemaLocation", "$projectDir/schemas")
|
||||
}
|
||||
buildConfigField("boolean", "DONATIONS", "true")
|
||||
buildConfigField("int", "TARGET_SDK", targetSdk.toString())
|
||||
}
|
||||
buildFeatures {
|
||||
buildConfig = true
|
||||
dataBinding = true
|
||||
viewBinding = true
|
||||
}
|
||||
@@ -57,6 +58,7 @@ android {
|
||||
}
|
||||
create("google") {
|
||||
dimension = "freedom"
|
||||
versionNameSuffix = "-g"
|
||||
buildConfigField("boolean", "DONATIONS", "false")
|
||||
}
|
||||
}
|
||||
@@ -64,37 +66,39 @@ android {
|
||||
}
|
||||
|
||||
dependencies {
|
||||
val lifecycleVersion = "2.3.1"
|
||||
val roomVersion = "2.3.0"
|
||||
val lifecycleVersion = "2.6.0-rc01"
|
||||
val roomVersion = "2.5.0"
|
||||
|
||||
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:1.1.5")
|
||||
coreLibraryDesugaring("com.android.tools:desugar_jdk_libs:2.0.2")
|
||||
kapt("androidx.room:room-compiler:$roomVersion")
|
||||
implementation(kotlin("stdlib-jdk8"))
|
||||
implementation("androidx.appcompat:appcompat:1.3.0") // https://issuetracker.google.com/issues/151603528
|
||||
implementation("androidx.browser:browser:1.3.0")
|
||||
implementation("androidx.core:core-ktx:1.6.0-beta01")
|
||||
implementation("androidx.emoji:emoji:1.1.0")
|
||||
implementation("androidx.fragment:fragment-ktx:1.3.4")
|
||||
implementation("androidx.lifecycle:lifecycle-common-java8:$lifecycleVersion")
|
||||
implementation("androidx.browser:browser:1.5.0")
|
||||
implementation("androidx.core:core-ktx:1.9.0")
|
||||
implementation("androidx.fragment:fragment-ktx:1.5.5")
|
||||
implementation("androidx.lifecycle:lifecycle-livedata-ktx:$lifecycleVersion")
|
||||
implementation("androidx.lifecycle:lifecycle-runtime-ktx:$lifecycleVersion")
|
||||
implementation("androidx.preference:preference:1.1.1")
|
||||
implementation("androidx.preference:preference:1.2.0")
|
||||
implementation("androidx.room:room-ktx:$roomVersion")
|
||||
implementation("androidx.swiperefreshlayout:swiperefreshlayout:1.1.0")
|
||||
implementation("com.android.billingclient:billing-ktx:4.0.0")
|
||||
implementation("be.mygod.librootkotlinx:librootkotlinx:1.0.1")
|
||||
implementation("com.android.billingclient:billing-ktx:5.1.0")
|
||||
implementation("com.github.tiann:FreeReflection:3.1.0")
|
||||
implementation("com.google.android.gms:play-services-base:18.2.0") // fix for GoogleApiActivity crash
|
||||
implementation("com.google.android.gms:play-services-oss-licenses:17.0.0")
|
||||
implementation("com.google.android.material:material:1.4.0-beta01")
|
||||
implementation("com.google.firebase:firebase-analytics-ktx:19.0.0")
|
||||
implementation("com.google.firebase:firebase-crashlytics:18.0.0")
|
||||
implementation("com.google.zxing:core:3.4.1")
|
||||
implementation("com.jakewharton.timber:timber:4.7.1")
|
||||
implementation("com.linkedin.dexmaker:dexmaker:2.28.1")
|
||||
implementation("com.google.android.material:material:1.8.0")
|
||||
implementation("com.google.firebase:firebase-analytics-ktx:21.2.0")
|
||||
implementation("com.google.firebase:firebase-crashlytics:18.3.5")
|
||||
implementation("com.google.zxing:core:3.5.1")
|
||||
implementation("com.jakewharton.timber:timber:5.0.1")
|
||||
implementation("com.linkedin.dexmaker:dexmaker:2.28.3")
|
||||
implementation("com.takisoft.preferencex:preferencex-simplemenu:1.1.0")
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-collections-immutable:0.3.4")
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.5.0")
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-collections-immutable:0.3.5")
|
||||
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.4")
|
||||
add("googleImplementation", "com.google.android.play:core:1.10.3")
|
||||
add("googleImplementation", "com.google.android.play:core-ktx:1.8.1")
|
||||
testImplementation("junit:junit:4.13.2")
|
||||
androidTestImplementation("androidx.room:room-testing:$roomVersion")
|
||||
androidTestImplementation("androidx.test:runner:1.3.0")
|
||||
androidTestImplementation("androidx.test.espresso:espresso-core:3.3.0")
|
||||
androidTestImplementation("androidx.test.ext:junit-ktx:1.1.2")
|
||||
androidTestImplementation("androidx.test:runner:1.5.2")
|
||||
androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1")
|
||||
androidTestImplementation("androidx.test.ext:junit-ktx:1.1.5")
|
||||
}
|
||||
|
||||
@@ -4,5 +4,6 @@
|
||||
<ignore regexp="org.mockito.*" />
|
||||
</issue>
|
||||
<issue id="MissingTranslation" severity="ignore" />
|
||||
<issue id="NewApi" severity="warning" />
|
||||
<issue id="UseAppTint" severity="informational" />
|
||||
</lint>
|
||||
|
||||
7
mobile/proguard-rules.pro
vendored
7
mobile/proguard-rules.pro
vendored
@@ -20,10 +20,3 @@
|
||||
# If you keep the line number information, uncomment this to
|
||||
# hide the original source file name.
|
||||
#-renamesourcefileattribute SourceFile
|
||||
|
||||
-if public class be.mygod.librootkotlinx.RootServer {
|
||||
private void doInit(android.content.Context, java.lang.String);
|
||||
}
|
||||
-keep class be.mygod.librootkotlinx.RootServer {
|
||||
public static void main(java.lang.String[]);
|
||||
}
|
||||
|
||||
@@ -34,10 +34,10 @@
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": false,
|
||||
"columnNames": [
|
||||
"mac"
|
||||
],
|
||||
"autoGenerate": false
|
||||
]
|
||||
},
|
||||
"indices": [],
|
||||
"foreignKeys": []
|
||||
@@ -114,10 +114,10 @@
|
||||
}
|
||||
],
|
||||
"primaryKey": {
|
||||
"autoGenerate": true,
|
||||
"columnNames": [
|
||||
"id"
|
||||
],
|
||||
"autoGenerate": true
|
||||
]
|
||||
},
|
||||
"indices": [
|
||||
{
|
||||
@@ -126,7 +126,8 @@
|
||||
"columnNames": [
|
||||
"previousId"
|
||||
],
|
||||
"createSql": "CREATE UNIQUE INDEX `index_TrafficRecord_previousId` ON `${TABLE_NAME}` (`previousId`)"
|
||||
"orders": [],
|
||||
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_TrafficRecord_previousId` ON `${TABLE_NAME}` (`previousId`)"
|
||||
}
|
||||
],
|
||||
"foreignKeys": [
|
||||
@@ -144,9 +145,10 @@
|
||||
]
|
||||
}
|
||||
],
|
||||
"views": [],
|
||||
"setupQueries": [
|
||||
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
|
||||
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, \"92a6c0406ed7265dbd98eb3c24095651\")"
|
||||
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '92a6c0406ed7265dbd98eb3c24095651')"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package be.mygod.vpnhotspot.util
|
||||
|
||||
import android.app.Activity
|
||||
import android.net.Uri
|
||||
import androidx.core.content.edit
|
||||
import be.mygod.vpnhotspot.App.Companion.app
|
||||
import be.mygod.vpnhotspot.BuildConfig
|
||||
import kotlinx.coroutines.currentCoroutineContext
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.ensureActive
|
||||
import kotlinx.coroutines.flow.cancellable
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import org.json.JSONArray
|
||||
import timber.log.Timber
|
||||
import java.io.IOException
|
||||
import java.time.Instant
|
||||
import java.util.concurrent.CancellationException
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
|
||||
object UpdateChecker {
|
||||
private const val KEY_LAST_FETCHED = "update.lastFetched"
|
||||
private const val KEY_VERSION = "update.version"
|
||||
private const val KEY_PUBLISHED = "update.published"
|
||||
private const val UPDATE_INTERVAL = 1000 * 60 * 60 * 6
|
||||
|
||||
private data class GitHubUpdate(override val message: String, val published: Long) : AppUpdate {
|
||||
override val stalenessDays get() = max(0,
|
||||
TimeUnit.DAYS.convert(System.currentTimeMillis() - published, TimeUnit.MILLISECONDS)).toInt()
|
||||
|
||||
override fun updateForResult(activity: Activity, requestCode: Int) {
|
||||
app.customTabsIntent.launchUrl(activity, Uri.parse("https://github.com/Mygod/VPNHotspot/releases"))
|
||||
}
|
||||
}
|
||||
|
||||
private data class SemVer(val major: Int, val minor: Int, val revision: Int) : Comparable<SemVer> {
|
||||
override fun compareTo(other: SemVer): Int {
|
||||
var result = major - other.major
|
||||
if (result != 0) return result
|
||||
result = minor - other.minor
|
||||
if (result != 0) return result
|
||||
return revision - other.revision
|
||||
}
|
||||
}
|
||||
private val semverParser = "^v?(\\d+)\\.(\\d+)\\.(\\d+)(?:-|$)".toPattern()
|
||||
private fun CharSequence.toSemVer() = semverParser.matcher(this).let { matcher ->
|
||||
require(matcher.find()) { "Unrecognized version $this" }
|
||||
SemVer(matcher.group(1)!!.toInt(), matcher.group(2)!!.toInt(), matcher.group(3)!!.toInt())
|
||||
}
|
||||
private val myVer = BuildConfig.VERSION_NAME.toSemVer()
|
||||
|
||||
private fun findUpdate(response: JSONArray): GitHubUpdate? {
|
||||
var latest: String? = null
|
||||
var latestVer = myVer
|
||||
var earliest = Long.MAX_VALUE
|
||||
for (i in 0 until response.length()) {
|
||||
val obj = response.getJSONObject(i)
|
||||
val name = obj.getString("name")
|
||||
val semver = try {
|
||||
name.toSemVer()
|
||||
} catch (e: IllegalArgumentException) {
|
||||
Timber.w(e)
|
||||
continue
|
||||
}
|
||||
if (semver <= myVer) continue
|
||||
if (semver > latestVer) {
|
||||
latest = name
|
||||
latestVer = semver
|
||||
}
|
||||
earliest = min(earliest, Instant.parse(obj.getString("published_at")).toEpochMilli())
|
||||
}
|
||||
return latest?.let { GitHubUpdate(it, earliest) }
|
||||
}
|
||||
fun check() = flow<AppUpdate?> {
|
||||
emit(app.pref.getString(KEY_VERSION, null)?.let {
|
||||
if (myVer >= it.toSemVer()) null else GitHubUpdate(it, app.pref.getLong(KEY_PUBLISHED, -1))
|
||||
})
|
||||
while (true) {
|
||||
val now = System.currentTimeMillis()
|
||||
val lastFetched = app.pref.getLong(KEY_LAST_FETCHED, -1)
|
||||
if (lastFetched in 0..now) delay(lastFetched + UPDATE_INTERVAL - now)
|
||||
currentCoroutineContext().ensureActive()
|
||||
var reset: Long? = null
|
||||
app.pref.edit {
|
||||
try {
|
||||
val update = findUpdate(JSONArray(connectCancellable(
|
||||
"https://api.github.com/repos/Mygod/VPNHotspot/releases?per_page=100") { conn ->
|
||||
conn.setRequestProperty("Accept", "application/vnd.github.v3+json")
|
||||
reset = conn.getHeaderField("X-RateLimit-Reset")?.toLongOrNull()
|
||||
conn.inputStream.bufferedReader().readText()
|
||||
}))
|
||||
putString(KEY_VERSION, update?.let {
|
||||
putLong(KEY_PUBLISHED, update.published)
|
||||
it.message
|
||||
})
|
||||
emit(update)
|
||||
} catch (_: CancellationException) {
|
||||
return@flow
|
||||
} catch (e: IOException) {
|
||||
Timber.d(e)
|
||||
} catch (e: Exception) {
|
||||
Timber.w(e)
|
||||
} finally {
|
||||
putLong(KEY_LAST_FETCHED, System.currentTimeMillis())
|
||||
}
|
||||
}
|
||||
reset?.let { delay(System.currentTimeMillis() - it * 1000) }
|
||||
}
|
||||
}.cancellable()
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package be.mygod.vpnhotspot.util
|
||||
|
||||
import android.app.Activity
|
||||
import android.net.Uri
|
||||
import be.mygod.vpnhotspot.App.Companion.app
|
||||
import com.google.android.play.core.appupdate.AppUpdateManagerFactory
|
||||
import com.google.android.play.core.install.InstallException
|
||||
import com.google.android.play.core.install.model.InstallErrorCode
|
||||
import com.google.android.play.core.install.model.InstallStatus
|
||||
import com.google.android.play.core.ktx.AppUpdateResult
|
||||
import com.google.android.play.core.ktx.requestUpdateFlow
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.flow.catch
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.launch
|
||||
import timber.log.Timber
|
||||
|
||||
object UpdateChecker {
|
||||
private class UpdateAvailable(private val update: AppUpdateResult.Available) : AppUpdate {
|
||||
override val stalenessDays get() = update.updateInfo.clientVersionStalenessDays() ?: 0
|
||||
override fun updateForResult(activity: Activity, requestCode: Int) = try {
|
||||
check(update.startFlexibleUpdate(activity, requestCode)) { "startFlexibleUpdate failed" }
|
||||
} catch (e: Exception) {
|
||||
Timber.w(e)
|
||||
app.customTabsIntent.launchUrl(activity,
|
||||
Uri.parse("https://play.google.com/store/apps/details?id=be.mygod.vpnhotspot"))
|
||||
}
|
||||
}
|
||||
private class UpdateDownloading(private val update: AppUpdateResult.InProgress) : AppUpdate {
|
||||
override val downloaded get() = false
|
||||
override val message: String? get() {
|
||||
if (update.installState.installStatus() != InstallStatus.FAILED) return null
|
||||
val code = update.installState.installErrorCode()
|
||||
for (f in InstallErrorCode::class.java.declaredFields) if (f.getInt(null) == code) return f.name
|
||||
return "Unrecognized Error"
|
||||
}
|
||||
}
|
||||
private class UpdateDownloaded(private val update: AppUpdateResult.Downloaded) : AppUpdate {
|
||||
override val downloaded get() = true
|
||||
override val stalenessDays get() = 0
|
||||
override fun updateForResult(activity: Activity, requestCode: Int) {
|
||||
GlobalScope.launch { update.completeUpdate() }
|
||||
}
|
||||
}
|
||||
|
||||
private val manager by lazy { AppUpdateManagerFactory.create(app) }
|
||||
|
||||
fun check() = manager.requestUpdateFlow().catch { e ->
|
||||
when (e) {
|
||||
is InstallException -> {
|
||||
app.logEvent("InstallErrorCode") { param("errorCode", e.errorCode.toLong()) }
|
||||
throw AppUpdate.IgnoredException(e)
|
||||
}
|
||||
is RuntimeException -> if (e.message == "Failed to bind to the service.") {
|
||||
app.logEvent("UpdateBindFailure")
|
||||
throw AppUpdate.IgnoredException(e)
|
||||
}
|
||||
}
|
||||
throw e
|
||||
}.map { result ->
|
||||
when (result) {
|
||||
is AppUpdateResult.NotAvailable -> null
|
||||
is AppUpdateResult.Available -> UpdateAvailable(result)
|
||||
is AppUpdateResult.InProgress -> {
|
||||
if (result.installState.installStatus() == InstallStatus.CANCELED) null else UpdateDownloading(result)
|
||||
}
|
||||
is AppUpdateResult.Downloaded -> UpdateDownloaded(result)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools"
|
||||
package="be.mygod.vpnhotspot">
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<uses-feature
|
||||
android:name="android.hardware.bluetooth"
|
||||
@@ -9,9 +8,6 @@
|
||||
<uses-feature
|
||||
android:name="android.hardware.ethernet"
|
||||
android:required="false"/>
|
||||
<uses-feature
|
||||
android:name="android.software.leanback"
|
||||
android:required="false"/>
|
||||
<uses-feature
|
||||
android:name="android.hardware.touchscreen"
|
||||
android:required="false"/>
|
||||
@@ -24,11 +20,15 @@
|
||||
<uses-feature
|
||||
android:name="android.hardware.wifi.direct"
|
||||
android:required="false"/>
|
||||
<uses-feature
|
||||
android:name="android.software.leanback"
|
||||
android:required="false"/>
|
||||
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
|
||||
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE"/>
|
||||
<uses-permission android:name="android.permission.BLUETOOTH"/>
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN"/>
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT"/>
|
||||
<uses-permission android:name="android.permission.CHANGE_NETWORK_STATE"/>
|
||||
<uses-permission android:name="android.permission.CHANGE_WIFI_STATE"/>
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
|
||||
@@ -37,8 +37,11 @@
|
||||
tools:ignore="ProtectedPermissions" />
|
||||
<uses-permission android:name="android.permission.MANAGE_USB"
|
||||
tools:ignore="ProtectedPermissions"/>
|
||||
<uses-permission android:name="android.permission.NEARBY_WIFI_DEVICES"
|
||||
android:usesPermissionFlags="neverForLocation"/>
|
||||
<uses-permission android:name="android.permission.OVERRIDE_WIFI_CONFIG"
|
||||
tools:ignore="ProtectedPermissions"/>
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
|
||||
<uses-permission android:name="android.permission.READ_WIFI_CREDENTIAL"
|
||||
tools:ignore="ProtectedPermissions"/>
|
||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
|
||||
@@ -49,11 +52,22 @@
|
||||
tools:ignore="ProtectedPermissions"/>
|
||||
<uses-permission android:name="android.permission.WRITE_SETTINGS"
|
||||
tools:ignore="ProtectedPermissions"/>
|
||||
<uses-permission-sdk-23 android:name="android.permission.ACCESS_COARSE_LOCATION"/>
|
||||
<!-- Required since API 29 -->
|
||||
<uses-permission-sdk-23 android:name="android.permission.ACCESS_FINE_LOCATION"/>
|
||||
<!-- Required since API 31 -->
|
||||
<uses-permission-sdk-23 android:name="android.permission.BLUETOOTH_CONNECT"/>
|
||||
<uses-permission-sdk-23 android:name="android.permission.ACCESS_COARSE_LOCATION"
|
||||
android:maxSdkVersion="32"/>
|
||||
<uses-permission-sdk-23 android:name="android.permission.ACCESS_FINE_LOCATION"
|
||||
android:maxSdkVersion="32"/>
|
||||
|
||||
<queries>
|
||||
<intent>
|
||||
<action android:name="android.net.ITetheringConnector" />
|
||||
</intent>
|
||||
<intent>
|
||||
<action android:name="android.net.ITetheringConnector.InProcess" />
|
||||
</intent>
|
||||
<intent>
|
||||
<action android:name="com.android.server.wifi.intent.action.SERVICE_WIFI_RESOURCES_APK" />
|
||||
</intent>
|
||||
</queries>
|
||||
|
||||
<application
|
||||
android:name=".App"
|
||||
@@ -63,7 +77,8 @@
|
||||
android:banner="@mipmap/banner"
|
||||
android:hasFragileUserData="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:roundIcon="@mipmap/ic_launcher_round"
|
||||
android:localeConfig="@xml/locales_config"
|
||||
android:enableOnBackInvokedCallback="true"
|
||||
android:supportsRtl="true"
|
||||
android:theme="@style/AppTheme"
|
||||
tools:ignore="GoogleAppIndexingWarning">
|
||||
@@ -71,7 +86,6 @@
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:label="@string/app_name"
|
||||
android:launchMode="singleTask"
|
||||
android:windowSoftInputMode="stateAlwaysHidden">
|
||||
<intent-filter>
|
||||
@@ -88,8 +102,7 @@
|
||||
<service
|
||||
android:name=".LocalOnlyHotspotService"
|
||||
android:directBootAware="true"
|
||||
android:foregroundServiceType="location|connectedDevice"
|
||||
tools:targetApi="26"/>
|
||||
android:foregroundServiceType="location|connectedDevice"/>
|
||||
<service
|
||||
android:name=".RepeaterService"
|
||||
android:directBootAware="true"
|
||||
@@ -105,8 +118,7 @@
|
||||
android:exported="true"
|
||||
android:icon="@drawable/ic_action_settings_input_antenna"
|
||||
android:label="@string/title_repeater"
|
||||
android:permission="android.permission.BIND_QUICK_SETTINGS_TILE"
|
||||
tools:targetApi="24">
|
||||
android:permission="android.permission.BIND_QUICK_SETTINGS_TILE">
|
||||
<intent-filter>
|
||||
<action android:name="android.service.quicksettings.action.QS_TILE" />
|
||||
</intent-filter>
|
||||
@@ -117,12 +129,10 @@
|
||||
<service
|
||||
android:name=".manage.LocalOnlyHotspotTileService"
|
||||
android:directBootAware="true"
|
||||
android:enabled="@bool/api_ge_26"
|
||||
android:exported="true"
|
||||
android:icon="@drawable/ic_action_perm_scan_wifi"
|
||||
android:label="@string/tethering_temp_hotspot"
|
||||
android:permission="android.permission.BIND_QUICK_SETTINGS_TILE"
|
||||
tools:targetApi="26">
|
||||
android:permission="android.permission.BIND_QUICK_SETTINGS_TILE">
|
||||
<intent-filter>
|
||||
<action android:name="android.service.quicksettings.action.QS_TILE" />
|
||||
</intent-filter>
|
||||
@@ -136,11 +146,13 @@
|
||||
android:exported="true"
|
||||
android:icon="@drawable/ic_device_wifi_tethering"
|
||||
android:label="@string/tethering_manage_wifi"
|
||||
android:permission="android.permission.BIND_QUICK_SETTINGS_TILE"
|
||||
tools:targetApi="24">
|
||||
android:permission="android.permission.BIND_QUICK_SETTINGS_TILE">
|
||||
<intent-filter>
|
||||
<action android:name="android.service.quicksettings.action.QS_TILE" />
|
||||
</intent-filter>
|
||||
<meta-data
|
||||
android:name="android.service.quicksettings.TOGGLEABLE_TILE"
|
||||
android:value="true" />
|
||||
</service>
|
||||
<service
|
||||
android:name=".manage.TetheringTileService$Usb"
|
||||
@@ -148,11 +160,13 @@
|
||||
android:exported="true"
|
||||
android:icon="@drawable/ic_device_usb"
|
||||
android:label="@string/tethering_manage_usb"
|
||||
android:permission="android.permission.BIND_QUICK_SETTINGS_TILE"
|
||||
tools:targetApi="24">
|
||||
android:permission="android.permission.BIND_QUICK_SETTINGS_TILE">
|
||||
<intent-filter>
|
||||
<action android:name="android.service.quicksettings.action.QS_TILE" />
|
||||
</intent-filter>
|
||||
<meta-data
|
||||
android:name="android.service.quicksettings.TOGGLEABLE_TILE"
|
||||
android:value="true" />
|
||||
</service>
|
||||
<service
|
||||
android:name=".manage.TetheringTileService$Bluetooth"
|
||||
@@ -160,11 +174,13 @@
|
||||
android:exported="true"
|
||||
android:icon="@drawable/ic_device_bluetooth"
|
||||
android:label="@string/tethering_manage_bluetooth"
|
||||
android:permission="android.permission.BIND_QUICK_SETTINGS_TILE"
|
||||
tools:targetApi="24">
|
||||
android:permission="android.permission.BIND_QUICK_SETTINGS_TILE">
|
||||
<intent-filter>
|
||||
<action android:name="android.service.quicksettings.action.QS_TILE" />
|
||||
</intent-filter>
|
||||
<meta-data
|
||||
android:name="android.service.quicksettings.TOGGLEABLE_TILE"
|
||||
android:value="true" />
|
||||
</service>
|
||||
<service
|
||||
android:name=".manage.TetheringTileService$Ethernet"
|
||||
@@ -178,46 +194,9 @@
|
||||
<intent-filter>
|
||||
<action android:name="android.service.quicksettings.action.QS_TILE" />
|
||||
</intent-filter>
|
||||
</service>
|
||||
<service
|
||||
android:name=".manage.TetheringTileService$Ncm"
|
||||
android:directBootAware="true"
|
||||
android:enabled="@bool/api_ge_30"
|
||||
android:exported="true"
|
||||
android:icon="@drawable/ic_action_settings_ethernet"
|
||||
android:label="@string/tethering_manage_ncm"
|
||||
android:permission="android.permission.BIND_QUICK_SETTINGS_TILE"
|
||||
tools:targetApi="30">
|
||||
<intent-filter>
|
||||
<action android:name="android.service.quicksettings.action.QS_TILE" />
|
||||
</intent-filter>
|
||||
</service>
|
||||
<service
|
||||
android:name=".manage.TetheringTileService$WiGig"
|
||||
android:directBootAware="true"
|
||||
android:enabled="@bool/api_ge_30"
|
||||
android:exported="true"
|
||||
android:icon="@drawable/ic_image_flash_on"
|
||||
android:label="@string/tethering_manage_wigig"
|
||||
android:permission="android.permission.BIND_QUICK_SETTINGS_TILE"
|
||||
tools:targetApi="30">
|
||||
<intent-filter>
|
||||
<action android:name="android.service.quicksettings.action.QS_TILE" />
|
||||
</intent-filter>
|
||||
</service>
|
||||
<!--suppress DeprecatedClassUsageInspection -->
|
||||
<service
|
||||
android:name=".manage.TetheringTileService$WifiLegacy"
|
||||
android:directBootAware="true"
|
||||
android:enabled="@bool/api_lt_25"
|
||||
android:exported="true"
|
||||
android:icon="@drawable/ic_device_wifi_tethering"
|
||||
android:label="@string/tethering_manage_wifi_legacy"
|
||||
android:permission="android.permission.BIND_QUICK_SETTINGS_TILE"
|
||||
tools:targetApi="24">
|
||||
<intent-filter>
|
||||
<action android:name="android.service.quicksettings.action.QS_TILE" />
|
||||
</intent-filter>
|
||||
<meta-data
|
||||
android:name="android.service.quicksettings.TOGGLEABLE_TILE"
|
||||
android:value="true" />
|
||||
</service>
|
||||
|
||||
<receiver
|
||||
@@ -228,6 +207,7 @@
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.BOOT_COMPLETED"/>
|
||||
<action android:name="android.intent.action.LOCKED_BOOT_COMPLETED" />
|
||||
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
|
||||
@@ -1,124 +0,0 @@
|
||||
package be.mygod.librootkotlinx
|
||||
|
||||
import android.os.Build
|
||||
import android.os.Debug
|
||||
import android.os.Process
|
||||
import androidx.annotation.RequiresApi
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
|
||||
object AppProcess {
|
||||
/**
|
||||
* Based on: https://android.googlesource.com/platform/bionic/+/aff9a34/linker/linker.cpp#3397
|
||||
*/
|
||||
@get:RequiresApi(28)
|
||||
val genericLdConfigFilePath: String get() {
|
||||
"/system/etc/ld.config.$currentInstructionSet.txt".let { if (File(it).isFile) return it }
|
||||
if (Build.VERSION.SDK_INT >= 30) "/linkerconfig/ld.config.txt".let {
|
||||
if (File(it).isFile) return it
|
||||
Logger.me.w("Failed to find generated linker configuration from \"$it\"")
|
||||
}
|
||||
if (isVndkLite) {
|
||||
"/system/etc/ld.config.vndk_lite.txt".let { if (File(it).isFile) return it }
|
||||
} else when (vndkVersion) {
|
||||
"", "current" -> { }
|
||||
else -> "/system/etc/ld.config.$vndkVersion.txt".let { if (File(it).isFile) return it }
|
||||
}
|
||||
return "/system/etc/ld.config.txt"
|
||||
}
|
||||
|
||||
/**
|
||||
* Based on: https://android.googlesource.com/platform/bionic/+/30f2f05/linker/linker_config.cpp#182
|
||||
*/
|
||||
@RequiresApi(26)
|
||||
fun findLinkerSection(lines: Sequence<String>, binaryRealPath: String): String {
|
||||
for (untrimmed in lines) {
|
||||
val line = untrimmed.substringBefore('#').trim()
|
||||
if (line.isEmpty()) continue
|
||||
if (line[0] == '[' && line.last() == ']') break
|
||||
if (line.contains("+=")) continue
|
||||
val chunks = line.split('=', limit = 2)
|
||||
if (chunks.size < 2) {
|
||||
Logger.me.w("warning: couldn't parse invalid format: $line (ignoring this line)")
|
||||
continue
|
||||
}
|
||||
var (name, value) = chunks.map { it.trim() }
|
||||
if (!name.startsWith("dir.")) {
|
||||
Logger.me.w("warning: unexpected property name \"$name\", " +
|
||||
"expected format dir.<section_name> (ignoring this line)")
|
||||
continue
|
||||
}
|
||||
if (value.endsWith('/')) value = value.dropLast(1)
|
||||
if (value.isEmpty()) {
|
||||
Logger.me.w("warning: property value is empty (ignoring this line)")
|
||||
continue
|
||||
}
|
||||
try {
|
||||
value = File(value).canonicalPath
|
||||
} catch (e: IOException) {
|
||||
Logger.me.i("warning: path \"$value\" couldn't be resolved: ${e.message}")
|
||||
}
|
||||
if (binaryRealPath.startsWith(value) && binaryRealPath[value.length] == '/') return name.substring(4)
|
||||
}
|
||||
throw IllegalArgumentException("No valid linker section found")
|
||||
}
|
||||
|
||||
val myExe get() = "/proc/${Process.myPid()}/exe"
|
||||
val myExeCanonical get() = try {
|
||||
File("/proc/self/exe").canonicalPath
|
||||
} catch (e: IOException) {
|
||||
Logger.me.i("warning: couldn't resolve self exe: ${e.message}")
|
||||
"/system/bin/app_process"
|
||||
}
|
||||
|
||||
/**
|
||||
* To workaround Samsung's stupid kernel patch that prevents exec, we need to relocate exe outside of /data.
|
||||
* See also: https://github.com/Chainfire/librootjava/issues/19
|
||||
*
|
||||
* @return The script to be executed to perform relocation and the relocated binary path.
|
||||
*/
|
||||
fun relocateScript(token: String): Pair<StringBuilder, String> {
|
||||
val script = StringBuilder()
|
||||
val (baseDir, relocated) = if (Build.VERSION.SDK_INT < 29) "/dev" to "/dev/app_process_$token" else {
|
||||
val apexPath = "/apex/$token"
|
||||
script.appendLine("[ -d $apexPath ] || " +
|
||||
"mkdir $apexPath && " +
|
||||
// we need to mount a new tmpfs to override noexec flag
|
||||
"mount -t tmpfs -o size=1M tmpfs $apexPath || exit 1")
|
||||
// unfortunately native ld.config.txt only recognizes /data,/system,/system_ext as system directories;
|
||||
// to link correctly, we need to add our path to the linker config too
|
||||
val ldConfig = "$apexPath/etc/ld.config.txt"
|
||||
val masterLdConfig = genericLdConfigFilePath
|
||||
val section = try {
|
||||
File(masterLdConfig).useLines { findLinkerSection(it, myExeCanonical) }
|
||||
} catch (e: Exception) {
|
||||
Logger.me.w("Failed to locate system section", e)
|
||||
"system"
|
||||
}
|
||||
script.appendLine("[ -f $ldConfig ] || " +
|
||||
"mkdir -p $apexPath/etc && " +
|
||||
"echo dir.$section = $apexPath >$ldConfig && " +
|
||||
"cat $masterLdConfig >>$ldConfig || exit 1")
|
||||
"$apexPath/bin" to "$apexPath/bin/app_process"
|
||||
}
|
||||
script.appendLine("[ -f $relocated ] || " +
|
||||
"mkdir -p $baseDir && " +
|
||||
"cp $myExe $relocated && " +
|
||||
"chmod 700 $relocated || exit 1")
|
||||
return script to relocated
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the shell script line that exec into the corresponding [clazz].
|
||||
* Extra params can be simply appended to the string.
|
||||
*/
|
||||
fun launchString(packageCodePath: String, clazz: String, appProcess: String, niceName: String? = null): String {
|
||||
val debugParams = if (Debug.isDebuggerConnected()) when (Build.VERSION.SDK_INT) {
|
||||
in 29..Int.MAX_VALUE -> "-XjdwpProvider:adbconnection"
|
||||
28 -> "-XjdwpProvider:adbconnection -XjdwpOptions:suspend=n,server=y -Xcompiler-option --debuggable"
|
||||
else -> "-Xrunjdwp:transport=dt_android_adb,suspend=n,server=y -Xcompiler-option --debuggable"
|
||||
} else ""
|
||||
val extraParams = if (niceName != null) " --nice-name=$niceName" else ""
|
||||
return "CLASSPATH=$packageCodePath exec $appProcess $debugParams /system/bin$extraParams $clazz"
|
||||
}
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
package be.mygod.librootkotlinx
|
||||
|
||||
import android.util.Log
|
||||
|
||||
interface Logger {
|
||||
companion object {
|
||||
/**
|
||||
* Override this variable to change default behavior,
|
||||
* which is to print to [android.util.Log] under tag "RootServer" except for [d].
|
||||
*/
|
||||
@JvmStatic
|
||||
var me = object : Logger { }
|
||||
|
||||
private const val TAG = "RootServer"
|
||||
}
|
||||
|
||||
fun d(m: String?, t: Throwable? = null) { }
|
||||
fun e(m: String?, t: Throwable? = null) {
|
||||
Log.e(TAG, m, t)
|
||||
}
|
||||
fun i(m: String?, t: Throwable? = null) {
|
||||
Log.i(TAG, m, t)
|
||||
}
|
||||
fun w(m: String?, t: Throwable? = null) {
|
||||
Log.w(TAG, m, t)
|
||||
}
|
||||
}
|
||||
@@ -1,485 +0,0 @@
|
||||
package be.mygod.librootkotlinx
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.os.Looper
|
||||
import android.os.Parcelable
|
||||
import android.os.RemoteException
|
||||
import android.system.Os
|
||||
import android.system.OsConstants
|
||||
import androidx.collection.LongSparseArray
|
||||
import androidx.collection.set
|
||||
import androidx.collection.valueIterator
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.channels.*
|
||||
import java.io.*
|
||||
import java.util.*
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import kotlin.system.exitProcess
|
||||
|
||||
class RootServer {
|
||||
private sealed class Callback(private val server: RootServer, private val index: Long,
|
||||
protected val classLoader: ClassLoader?) {
|
||||
var active = true
|
||||
|
||||
abstract fun cancel()
|
||||
abstract fun shouldRemove(result: Byte): Boolean
|
||||
abstract operator fun invoke(input: DataInputStream, result: Byte)
|
||||
fun sendClosed() = server.execute(CancelCommand(index))
|
||||
|
||||
private fun initException(targetClass: Class<*>, message: String): Throwable {
|
||||
@Suppress("NAME_SHADOWING")
|
||||
var targetClass = targetClass
|
||||
while (true) {
|
||||
try {
|
||||
// try to find a message constructor
|
||||
return targetClass.getDeclaredConstructor(String::class.java).newInstance(message) as Throwable
|
||||
} catch (_: ReflectiveOperationException) { }
|
||||
targetClass = targetClass.superclass
|
||||
}
|
||||
}
|
||||
private fun makeRemoteException(cause: Throwable, message: String? = null) =
|
||||
if (cause is CancellationException) cause else RemoteException(message).initCause(cause)
|
||||
protected fun DataInputStream.readException(result: Byte) = when (result.toInt()) {
|
||||
EX_GENERIC -> {
|
||||
val message = readUTF()
|
||||
val name = message.split(':', limit = 2)[0]
|
||||
makeRemoteException(initException(try {
|
||||
classLoader?.loadClass(name)
|
||||
} catch (_: ClassNotFoundException) {
|
||||
null
|
||||
} ?: Class.forName(name), message), message)
|
||||
}
|
||||
EX_PARCELABLE -> makeRemoteException(readParcelable<Parcelable>(classLoader) as Throwable)
|
||||
EX_SERIALIZABLE -> makeRemoteException(readSerializable(classLoader) as Throwable)
|
||||
else -> throw IllegalArgumentException("Unexpected result $result")
|
||||
}
|
||||
|
||||
class Ordinary(server: RootServer, index: Long, classLoader: ClassLoader?,
|
||||
private val callback: CompletableDeferred<Parcelable?>) : Callback(server, index, classLoader) {
|
||||
override fun cancel() = callback.cancel()
|
||||
override fun shouldRemove(result: Byte) = true
|
||||
override fun invoke(input: DataInputStream, result: Byte) {
|
||||
if (result.toInt() == SUCCESS) callback.complete(input.readParcelable(classLoader))
|
||||
else callback.completeExceptionally(input.readException(result))
|
||||
}
|
||||
}
|
||||
|
||||
class Channel(server: RootServer, index: Long, classLoader: ClassLoader?,
|
||||
private val channel: SendChannel<Parcelable?>) : Callback(server, index, classLoader) {
|
||||
val finish: CompletableDeferred<Unit> = CompletableDeferred()
|
||||
override fun cancel() = finish.cancel()
|
||||
override fun shouldRemove(result: Byte) = result.toInt() != SUCCESS
|
||||
override fun invoke(input: DataInputStream, result: Byte) {
|
||||
when (result.toInt()) {
|
||||
SUCCESS -> channel.trySend(input.readParcelable(classLoader)).onClosed {
|
||||
active = false
|
||||
sendClosed()
|
||||
finish.completeExceptionally(it
|
||||
?: ClosedSendChannelException("Channel was closed normally"))
|
||||
return
|
||||
}.onFailure { throw it!! } // the channel we are supporting should never block
|
||||
CHANNEL_CONSUMED -> finish.complete(Unit)
|
||||
else -> finish.completeExceptionally(input.readException(result))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class UnexpectedExitException : RemoteException("Root process exited unexpectedly")
|
||||
|
||||
private lateinit var process: Process
|
||||
/**
|
||||
* Thread safety: needs to be protected by callbackLookup.
|
||||
*/
|
||||
private lateinit var output: DataOutputStream
|
||||
|
||||
@Volatile
|
||||
var active = false
|
||||
private var counter = 0L
|
||||
private var callbackListenerExit: Deferred<Unit>? = null
|
||||
private val callbackLookup = LongSparseArray<Callback>()
|
||||
|
||||
private fun readUnexpectedStderr(): String? {
|
||||
if (!this::process.isInitialized) return null
|
||||
var available = process.errorStream.available()
|
||||
return if (available <= 0) null else String(ByteArrayOutputStream().apply {
|
||||
try {
|
||||
while (available > 0) {
|
||||
val bytes = ByteArray(available)
|
||||
val len = process.errorStream.read(bytes)
|
||||
if (len < 0) throw EOFException() // should not happen
|
||||
write(bytes, 0, len)
|
||||
available = process.errorStream.available()
|
||||
}
|
||||
} catch (e: IOException) {
|
||||
Logger.me.w("Reading stderr was cut short", e)
|
||||
}
|
||||
}.toByteArray())
|
||||
}
|
||||
|
||||
private fun BufferedReader.lookForToken(token: String) {
|
||||
while (true) {
|
||||
val line = readLine() ?: throw EOFException()
|
||||
if (line.endsWith(token)) {
|
||||
val extraLength = line.length - token.length
|
||||
if (extraLength > 0) Logger.me.w(line.substring(0, extraLength))
|
||||
break
|
||||
}
|
||||
Logger.me.w(line)
|
||||
}
|
||||
}
|
||||
private fun doInit(context: Context, niceName: String) {
|
||||
val (reader, writer) = try {
|
||||
process = ProcessBuilder("su").start()
|
||||
val token1 = UUID.randomUUID().toString()
|
||||
val writer = DataOutputStream(process.outputStream.buffered())
|
||||
writer.writeBytes("echo $token1\n")
|
||||
writer.flush()
|
||||
val reader = process.inputStream.bufferedReader()
|
||||
reader.lookForToken(token1)
|
||||
Logger.me.d("Root shell initialized")
|
||||
reader to writer
|
||||
} catch (e: Exception) {
|
||||
throw NoShellException(e)
|
||||
}
|
||||
try {
|
||||
val token2 = UUID.randomUUID().toString()
|
||||
val persistence = File(context.codeCacheDir, ".librootkotlinx-uuid")
|
||||
val uuid = context.packageName + '@' + try {
|
||||
persistence.readText()
|
||||
} catch (_: FileNotFoundException) {
|
||||
UUID.randomUUID().toString().also { persistence.writeText(it) }
|
||||
}
|
||||
val (script, relocated) = AppProcess.relocateScript(uuid)
|
||||
script.appendLine(AppProcess.launchString(context.packageCodePath, RootServer::class.java.name, relocated,
|
||||
niceName) + " $token2")
|
||||
writer.writeBytes(script.toString())
|
||||
writer.flush()
|
||||
reader.lookForToken(token2) // wait for ready signal
|
||||
} catch (e: Exception) {
|
||||
throw RuntimeException("Failed to launch root daemon", e)
|
||||
}
|
||||
output = writer
|
||||
require(!active)
|
||||
active = true
|
||||
Logger.me.d("Root server initialized")
|
||||
}
|
||||
|
||||
private fun callbackSpin() {
|
||||
val input = DataInputStream(process.inputStream.buffered())
|
||||
while (active) {
|
||||
val index = try {
|
||||
input.readLong()
|
||||
} catch (_: EOFException) {
|
||||
break
|
||||
}
|
||||
val result = input.readByte()
|
||||
val callback = synchronized(callbackLookup) {
|
||||
if (active) (callbackLookup[index] ?: error("Empty callback #$index")).also {
|
||||
if (it.shouldRemove(result)) {
|
||||
callbackLookup.remove(index)
|
||||
it.active = false
|
||||
}
|
||||
} else null
|
||||
} ?: break
|
||||
Logger.me.d("Received callback #$index: $result")
|
||||
callback(input, result)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize a RootServer synchronously, can throw a lot of exceptions.
|
||||
*
|
||||
* @param context Any [Context] from the app.
|
||||
* @param niceName Name to call the rooted Java process.
|
||||
*/
|
||||
suspend fun init(context: Context, niceName: String = "${context.packageName}:root") {
|
||||
withContext(Dispatchers.IO) {
|
||||
try {
|
||||
doInit(context, niceName)
|
||||
} finally {
|
||||
try {
|
||||
readUnexpectedStderr()?.let { Logger.me.e(it) }
|
||||
} catch (e: IOException) {
|
||||
Logger.me.e("Failed to read from stderr", e) // avoid the real exception being swallowed
|
||||
}
|
||||
}
|
||||
}
|
||||
callbackListenerExit = GlobalScope.async(Dispatchers.IO) {
|
||||
val errorReader = async(Dispatchers.IO) {
|
||||
try {
|
||||
process.errorStream.bufferedReader().forEachLine(Logger.me::w)
|
||||
} catch (_: IOException) { }
|
||||
}
|
||||
try {
|
||||
callbackSpin()
|
||||
if (active) throw UnexpectedExitException()
|
||||
} catch (e: Throwable) {
|
||||
process.destroy()
|
||||
throw e
|
||||
} finally {
|
||||
Logger.me.d("Waiting for exit")
|
||||
withContext(NonCancellable) { errorReader.await() }
|
||||
process.waitFor()
|
||||
closeInternal(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Caller should check for active.
|
||||
*/
|
||||
private fun sendLocked(command: Parcelable) {
|
||||
output.writeParcelable(command)
|
||||
output.flush()
|
||||
Logger.me.d("Sent #$counter: $command")
|
||||
counter++
|
||||
}
|
||||
|
||||
fun execute(command: RootCommandOneWay) = synchronized(callbackLookup) { if (active) sendLocked(command) }
|
||||
@Throws(RemoteException::class)
|
||||
suspend inline fun <reified T : Parcelable?> execute(command: RootCommand<T>) =
|
||||
execute(command, T::class.java.classLoader)
|
||||
@Throws(RemoteException::class)
|
||||
suspend fun <T : Parcelable?> execute(command: RootCommand<T>, classLoader: ClassLoader?): T {
|
||||
val future = CompletableDeferred<T>()
|
||||
val callback = synchronized(callbackLookup) {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val callback = Callback.Ordinary(this, counter, classLoader, future as CompletableDeferred<Parcelable?>)
|
||||
if (active) {
|
||||
callbackLookup[counter] = callback
|
||||
sendLocked(command)
|
||||
} else future.cancel()
|
||||
callback
|
||||
}
|
||||
try {
|
||||
return future.await()
|
||||
} finally {
|
||||
if (callback.active) callback.sendClosed()
|
||||
callback.active = false
|
||||
}
|
||||
}
|
||||
|
||||
@ExperimentalCoroutinesApi
|
||||
@Throws(RemoteException::class)
|
||||
inline fun <reified T : Parcelable?> create(command: RootCommandChannel<T>, scope: CoroutineScope) =
|
||||
create(command, scope, T::class.java.classLoader)
|
||||
@ExperimentalCoroutinesApi
|
||||
@Throws(RemoteException::class)
|
||||
fun <T : Parcelable?> create(command: RootCommandChannel<T>, scope: CoroutineScope,
|
||||
classLoader: ClassLoader?) = scope.produce<T>(
|
||||
SupervisorJob(), command.capacity.also {
|
||||
when (it) {
|
||||
Channel.UNLIMITED, Channel.CONFLATED -> { }
|
||||
else -> throw IllegalArgumentException("Unsupported channel capacity $it")
|
||||
}
|
||||
}) {
|
||||
val callback = synchronized(callbackLookup) {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
val callback = Callback.Channel(this@RootServer, counter, classLoader, this as SendChannel<Parcelable?>)
|
||||
if (active) {
|
||||
callbackLookup[counter] = callback
|
||||
sendLocked(command)
|
||||
} else callback.finish.cancel()
|
||||
callback
|
||||
}
|
||||
try {
|
||||
callback.finish.await()
|
||||
} finally {
|
||||
if (callback.active) callback.sendClosed()
|
||||
callback.active = false
|
||||
}
|
||||
}
|
||||
|
||||
private fun closeInternal(fromWorker: Boolean = false) = synchronized(callbackLookup) {
|
||||
if (active) {
|
||||
active = false
|
||||
Logger.me.d(if (fromWorker) "Shutting down from worker" else "Shutting down from client")
|
||||
try {
|
||||
sendLocked(Shutdown())
|
||||
output.close()
|
||||
process.outputStream.close()
|
||||
} catch (e: IOException) {
|
||||
if (!e.isEBADF) Logger.me.w("send Shutdown failed", e)
|
||||
}
|
||||
Logger.me.d("Client closed")
|
||||
}
|
||||
if (fromWorker) {
|
||||
for (callback in callbackLookup.valueIterator()) callback.cancel()
|
||||
callbackLookup.clear()
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Shutdown the instance gracefully.
|
||||
*/
|
||||
suspend fun close() {
|
||||
closeInternal()
|
||||
val callbackListenerExit = callbackListenerExit ?: return
|
||||
try {
|
||||
withTimeout(10000) { callbackListenerExit.await() }
|
||||
} catch (e: TimeoutCancellationException) {
|
||||
Logger.me.w("Closing the instance has timed out", e)
|
||||
if (Build.VERSION.SDK_INT < 26) process.destroy() else if (process.isAlive) process.destroyForcibly()
|
||||
} catch (e: UnexpectedExitException) {
|
||||
Logger.me.w(e.message)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val SUCCESS = 0
|
||||
private const val EX_GENERIC = 1
|
||||
private const val EX_PARCELABLE = 2
|
||||
private const val EX_SERIALIZABLE = 4
|
||||
private const val CHANNEL_CONSUMED = 3
|
||||
|
||||
private fun DataInputStream.readByteArray() = ByteArray(readInt()).also { readFully(it) }
|
||||
|
||||
private inline fun <reified T : Parcelable> DataInputStream.readParcelable(
|
||||
classLoader: ClassLoader? = T::class.java.classLoader) = readByteArray().toParcelable<T>(classLoader)
|
||||
private fun DataOutputStream.writeParcelable(data: Parcelable?, parcelableFlags: Int = 0) {
|
||||
val bytes = data.toByteArray(parcelableFlags)
|
||||
writeInt(bytes.size)
|
||||
write(bytes)
|
||||
}
|
||||
|
||||
private fun DataInputStream.readSerializable(classLoader: ClassLoader?) =
|
||||
object : ObjectInputStream(ByteArrayInputStream(readByteArray())) {
|
||||
override fun resolveClass(desc: ObjectStreamClass) = Class.forName(desc.name, false, classLoader)
|
||||
}.readObject()
|
||||
|
||||
@JvmStatic
|
||||
fun main(args: Array<String>) {
|
||||
Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
|
||||
Logger.me.e("Uncaught exception from $thread", throwable)
|
||||
throwable.printStackTrace() // stderr will be read by listener
|
||||
exitProcess(1)
|
||||
}
|
||||
rootMain(args)
|
||||
exitProcess(0) // there might be other non-daemon threads
|
||||
}
|
||||
|
||||
private fun DataOutputStream.pushThrowable(callback: Long, e: Throwable) {
|
||||
writeLong(callback)
|
||||
if (e is Parcelable) {
|
||||
writeByte(EX_PARCELABLE)
|
||||
writeParcelable(e)
|
||||
} else try {
|
||||
val bytes = ByteArrayOutputStream().apply {
|
||||
ObjectOutputStream(this).use { it.writeObject(e) }
|
||||
}.toByteArray()
|
||||
writeByte(EX_SERIALIZABLE)
|
||||
writeInt(bytes.size)
|
||||
write(bytes)
|
||||
} catch (_: NotSerializableException) {
|
||||
writeByte(EX_GENERIC)
|
||||
writeUTF(e.stackTraceToString())
|
||||
}
|
||||
flush()
|
||||
}
|
||||
private fun DataOutputStream.pushResult(callback: Long, result: Parcelable?) {
|
||||
writeLong(callback)
|
||||
writeByte(SUCCESS)
|
||||
writeParcelable(result)
|
||||
flush()
|
||||
}
|
||||
|
||||
private fun rootMain(args: Array<String>) {
|
||||
require(args.isNotEmpty())
|
||||
val mainInitialized = CountDownLatch(1)
|
||||
val main = Thread({
|
||||
@Suppress("DEPRECATION")
|
||||
Looper.prepareMainLooper()
|
||||
mainInitialized.countDown()
|
||||
Looper.loop()
|
||||
}, "main")
|
||||
main.start()
|
||||
val job = Job()
|
||||
val defaultWorker by lazy {
|
||||
mainInitialized.await()
|
||||
CoroutineScope(Dispatchers.Main.immediate + job)
|
||||
}
|
||||
val callbackWorker = newSingleThreadContext("callbackWorker")
|
||||
val cancellables = LongSparseArray<() -> Unit>()
|
||||
|
||||
// thread safety: usage of output should be guarded by callbackWorker
|
||||
val output = DataOutputStream(FileOutputStream(Os.dup(FileDescriptor.out)).buffered().apply {
|
||||
// prevent future write attempts to System.out, possibly from Samsung changes (again)
|
||||
Os.dup2(FileDescriptor.err, OsConstants.STDOUT_FILENO)
|
||||
System.setOut(System.err)
|
||||
val writer = writer()
|
||||
writer.appendLine(args[0]) // echo ready signal
|
||||
writer.flush()
|
||||
})
|
||||
// thread safety: usage of input should be in main thread
|
||||
val input = DataInputStream(System.`in`.buffered())
|
||||
var counter = 0L
|
||||
Logger.me.d("Server entering main loop")
|
||||
loop@ while (true) {
|
||||
val command = try {
|
||||
input.readParcelable<Parcelable>(RootServer::class.java.classLoader)
|
||||
} catch (_: EOFException) {
|
||||
break
|
||||
}
|
||||
val callback = counter
|
||||
Logger.me.d("Received #$callback: $command")
|
||||
when (command) {
|
||||
is CancelCommand -> cancellables[command.index]?.invoke()
|
||||
is RootCommandOneWay -> defaultWorker.launch {
|
||||
try {
|
||||
command.execute()
|
||||
} catch (e: Throwable) {
|
||||
Logger.me.e("Unexpected exception in RootCommandOneWay ($command.javaClass.simpleName)", e)
|
||||
}
|
||||
}
|
||||
is RootCommand<*> -> {
|
||||
val commandJob = Job()
|
||||
cancellables[callback] = { commandJob.cancel() }
|
||||
defaultWorker.launch(commandJob) {
|
||||
val result = try {
|
||||
val result = command.execute();
|
||||
{ output.pushResult(callback, result) }
|
||||
} catch (e: Throwable) {
|
||||
val worker = { output.pushThrowable(callback, e) }
|
||||
worker
|
||||
} finally {
|
||||
cancellables.remove(callback)
|
||||
}
|
||||
withContext(callbackWorker + NonCancellable) { result() }
|
||||
}
|
||||
}
|
||||
is RootCommandChannel<*> -> defaultWorker.launch {
|
||||
val result = try {
|
||||
coroutineScope {
|
||||
command.create(this).also {
|
||||
cancellables[callback] = { it.cancel() }
|
||||
}.consumeEach { result ->
|
||||
withContext(callbackWorker) { output.pushResult(callback, result) }
|
||||
}
|
||||
};
|
||||
@Suppress("BlockingMethodInNonBlockingContext") {
|
||||
output.writeByte(CHANNEL_CONSUMED)
|
||||
output.writeLong(callback)
|
||||
output.flush()
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
val worker = { output.pushThrowable(callback, e) }
|
||||
worker
|
||||
} finally {
|
||||
cancellables.remove(callback)
|
||||
}
|
||||
withContext(callbackWorker + NonCancellable) { result() }
|
||||
}
|
||||
is Shutdown -> break@loop
|
||||
else -> throw IllegalArgumentException("Unrecognized input: $command")
|
||||
}
|
||||
counter++
|
||||
}
|
||||
job.cancel()
|
||||
Logger.me.d("Clean up initiated before exit. Jobs: ${job.children.joinToString()}")
|
||||
if (runBlocking { withTimeoutOrNull(5000) { job.join() } } == null) {
|
||||
Logger.me.w("Clean up timeout: ${job.children.joinToString()}")
|
||||
} else Logger.me.d("Clean up finished, exiting")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,108 +0,0 @@
|
||||
package be.mygod.librootkotlinx
|
||||
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlin.coroutines.CoroutineContext
|
||||
|
||||
/**
|
||||
* This object manages creation of [RootServer] and times them out automagically, with default timeout of 5 minutes.
|
||||
*/
|
||||
abstract class RootSession {
|
||||
protected abstract suspend fun initServer(server: RootServer)
|
||||
/**
|
||||
* Timeout to close [RootServer] in milliseconds.
|
||||
*/
|
||||
protected open val timeout get() = TimeUnit.MINUTES.toMillis(5)
|
||||
protected open val timeoutContext: CoroutineContext get() = Dispatchers.Default
|
||||
|
||||
private val mutex = Mutex()
|
||||
private var server: RootServer? = null
|
||||
private var timeoutJob: Job? = null
|
||||
private var usersCount = 0L
|
||||
private var closePending = false
|
||||
|
||||
private suspend fun ensureServerLocked(): RootServer {
|
||||
server?.let {
|
||||
if (it.active) return it
|
||||
usersCount = 0
|
||||
closeLocked()
|
||||
}
|
||||
check(usersCount == 0L) { "Unexpected $server, $usersCount" }
|
||||
val server = RootServer()
|
||||
try {
|
||||
initServer(server)
|
||||
this.server = server
|
||||
return server
|
||||
} catch (e: Throwable) {
|
||||
try {
|
||||
server.close()
|
||||
} catch (eClose: Throwable) {
|
||||
e.addSuppressed(eClose)
|
||||
}
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun closeLocked() {
|
||||
closePending = false
|
||||
val server = server
|
||||
this.server = null
|
||||
server?.close()
|
||||
}
|
||||
private fun startTimeoutLocked() {
|
||||
check(timeoutJob == null)
|
||||
timeoutJob = GlobalScope.launch(timeoutContext, CoroutineStart.UNDISPATCHED) {
|
||||
delay(timeout)
|
||||
mutex.withLock {
|
||||
check(usersCount == 0L)
|
||||
timeoutJob = null
|
||||
closeLocked()
|
||||
}
|
||||
}
|
||||
}
|
||||
private fun haltTimeoutLocked() {
|
||||
timeoutJob?.cancel()
|
||||
timeoutJob = null
|
||||
}
|
||||
|
||||
suspend fun acquire() = withContext(NonCancellable) {
|
||||
mutex.withLock {
|
||||
haltTimeoutLocked()
|
||||
closePending = false
|
||||
ensureServerLocked().also { ++usersCount }
|
||||
}
|
||||
}
|
||||
suspend fun release(server: RootServer) = withContext(NonCancellable) {
|
||||
mutex.withLock {
|
||||
if (this@RootSession.server != server) return@withLock // outdated reference
|
||||
require(usersCount > 0)
|
||||
when {
|
||||
!server.active -> {
|
||||
usersCount = 0
|
||||
closeLocked()
|
||||
return@withLock
|
||||
}
|
||||
--usersCount > 0L -> return@withLock
|
||||
closePending -> closeLocked()
|
||||
else -> startTimeoutLocked()
|
||||
}
|
||||
}
|
||||
}
|
||||
suspend inline fun <T> use(block: (RootServer) -> T): T {
|
||||
val server = acquire()
|
||||
try {
|
||||
return block(server)
|
||||
} finally {
|
||||
release(server)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun closeExisting() = mutex.withLock {
|
||||
if (usersCount > 0) closePending = true else {
|
||||
haltTimeoutLocked()
|
||||
closeLocked()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
package be.mygod.librootkotlinx
|
||||
|
||||
import android.os.Parcelable
|
||||
import androidx.annotation.MainThread
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.channels.ReceiveChannel
|
||||
import kotlinx.parcelize.Parcelize
|
||||
|
||||
interface RootCommand<Result : Parcelable?> : Parcelable {
|
||||
/**
|
||||
* If a throwable was thrown, it will be wrapped in RemoteException only if it implements [Parcelable].
|
||||
*/
|
||||
@MainThread
|
||||
suspend fun execute(): Result
|
||||
}
|
||||
|
||||
typealias RootCommandNoResult = RootCommand<Parcelable?>
|
||||
|
||||
/**
|
||||
* Execute a command and discards its result, even if an exception occurs.
|
||||
*
|
||||
* If you want to catch exception, use e.g. [RootCommandNoResult] and return null.
|
||||
*/
|
||||
interface RootCommandOneWay : Parcelable {
|
||||
@MainThread
|
||||
suspend fun execute()
|
||||
}
|
||||
|
||||
interface RootCommandChannel<T : Parcelable?> : Parcelable {
|
||||
/**
|
||||
* The capacity of the channel that is returned by [create] to be used by client.
|
||||
* Only [Channel.UNLIMITED] and [Channel.CONFLATED] is supported for now to avoid blocking the entire connection.
|
||||
*/
|
||||
val capacity: Int get() = Channel.UNLIMITED
|
||||
|
||||
@MainThread
|
||||
fun create(scope: CoroutineScope): ReceiveChannel<T>
|
||||
}
|
||||
|
||||
@Parcelize
|
||||
internal data class CancelCommand(val index: Long) : RootCommandOneWay {
|
||||
override suspend fun execute() = error("Internal implementation")
|
||||
}
|
||||
|
||||
@Parcelize
|
||||
internal class Shutdown : Parcelable
|
||||
@@ -1,255 +0,0 @@
|
||||
@file:JvmName("Utils")
|
||||
|
||||
package be.mygod.librootkotlinx
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.content.Context
|
||||
import android.os.Parcel
|
||||
import android.os.Parcelable
|
||||
import android.system.ErrnoException
|
||||
import android.system.OsConstants
|
||||
import android.util.*
|
||||
import androidx.annotation.RequiresApi
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import java.io.IOException
|
||||
|
||||
class NoShellException(cause: Throwable) : Exception("Root missing", cause)
|
||||
|
||||
internal val currentInstructionSet by lazy {
|
||||
val classVMRuntime = Class.forName("dalvik.system.VMRuntime")
|
||||
val runtime = classVMRuntime.getDeclaredMethod("getRuntime").invoke(null)
|
||||
classVMRuntime.getDeclaredMethod("getCurrentInstructionSet").invoke(runtime) as String
|
||||
}
|
||||
|
||||
private val classSystemProperties by lazy { Class.forName("android.os.SystemProperties") }
|
||||
@get:RequiresApi(26)
|
||||
internal val isVndkLite by lazy {
|
||||
classSystemProperties.getDeclaredMethod("getBoolean", String::class.java, Boolean::class.java).invoke(null,
|
||||
"ro.vndk.lite", false) as Boolean
|
||||
}
|
||||
@get:RequiresApi(26)
|
||||
internal val vndkVersion by lazy {
|
||||
classSystemProperties.getDeclaredMethod("get", String::class.java, String::class.java).invoke(null,
|
||||
"ro.vndk.version", "") as String
|
||||
}
|
||||
|
||||
val systemContext by lazy {
|
||||
val classActivityThread = Class.forName("android.app.ActivityThread")
|
||||
val activityThread = classActivityThread.getMethod("systemMain").invoke(null)
|
||||
classActivityThread.getMethod("getSystemContext").invoke(activityThread) as Context
|
||||
}
|
||||
|
||||
@Parcelize
|
||||
data class ParcelableByte(val value: Byte) : Parcelable
|
||||
|
||||
@Parcelize
|
||||
data class ParcelableShort(val value: Short) : Parcelable
|
||||
|
||||
@Parcelize
|
||||
data class ParcelableInt(val value: Int) : Parcelable
|
||||
|
||||
@Parcelize
|
||||
data class ParcelableLong(val value: Long) : Parcelable
|
||||
|
||||
@Parcelize
|
||||
data class ParcelableFloat(val value: Float) : Parcelable
|
||||
|
||||
@Parcelize
|
||||
data class ParcelableDouble(val value: Double) : Parcelable
|
||||
|
||||
@Parcelize
|
||||
data class ParcelableBoolean(val value: Boolean) : Parcelable
|
||||
|
||||
@Parcelize
|
||||
data class ParcelableString(val value: String) : Parcelable
|
||||
|
||||
@Parcelize
|
||||
data class ParcelableByteArray(val value: ByteArray) : Parcelable {
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (javaClass != other?.javaClass) return false
|
||||
|
||||
other as ParcelableByteArray
|
||||
|
||||
if (!value.contentEquals(other.value)) return false
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
return value.contentHashCode()
|
||||
}
|
||||
}
|
||||
|
||||
@Parcelize
|
||||
data class ParcelableIntArray(val value: IntArray) : Parcelable {
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (javaClass != other?.javaClass) return false
|
||||
|
||||
other as ParcelableIntArray
|
||||
|
||||
if (!value.contentEquals(other.value)) return false
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
return value.contentHashCode()
|
||||
}
|
||||
}
|
||||
|
||||
@Parcelize
|
||||
data class ParcelableLongArray(val value: LongArray) : Parcelable {
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (javaClass != other?.javaClass) return false
|
||||
|
||||
other as ParcelableLongArray
|
||||
|
||||
if (!value.contentEquals(other.value)) return false
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
return value.contentHashCode()
|
||||
}
|
||||
}
|
||||
|
||||
@Parcelize
|
||||
data class ParcelableFloatArray(val value: FloatArray) : Parcelable {
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (javaClass != other?.javaClass) return false
|
||||
|
||||
other as ParcelableFloatArray
|
||||
|
||||
if (!value.contentEquals(other.value)) return false
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
return value.contentHashCode()
|
||||
}
|
||||
}
|
||||
|
||||
@Parcelize
|
||||
data class ParcelableDoubleArray(val value: DoubleArray) : Parcelable {
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (javaClass != other?.javaClass) return false
|
||||
|
||||
other as ParcelableDoubleArray
|
||||
|
||||
if (!value.contentEquals(other.value)) return false
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
return value.contentHashCode()
|
||||
}
|
||||
}
|
||||
|
||||
@Parcelize
|
||||
data class ParcelableBooleanArray(val value: BooleanArray) : Parcelable {
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (javaClass != other?.javaClass) return false
|
||||
|
||||
other as ParcelableBooleanArray
|
||||
|
||||
if (!value.contentEquals(other.value)) return false
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
return value.contentHashCode()
|
||||
}
|
||||
}
|
||||
|
||||
@Parcelize
|
||||
data class ParcelableStringArray(val value: Array<String>) : Parcelable {
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (javaClass != other?.javaClass) return false
|
||||
|
||||
other as ParcelableStringArray
|
||||
|
||||
if (!value.contentEquals(other.value)) return false
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
return value.contentHashCode()
|
||||
}
|
||||
}
|
||||
|
||||
@Parcelize
|
||||
data class ParcelableStringList(val value: List<String>) : Parcelable
|
||||
|
||||
@Parcelize
|
||||
data class ParcelableSparseIntArray(val value: SparseIntArray) : Parcelable
|
||||
|
||||
@Parcelize
|
||||
data class ParcelableSparseLongArray(val value: SparseLongArray) : Parcelable
|
||||
|
||||
@Parcelize
|
||||
data class ParcelableSparseBooleanArray(val value: SparseBooleanArray) : Parcelable
|
||||
|
||||
@Parcelize
|
||||
data class ParcelableCharSequence(val value: CharSequence) : Parcelable
|
||||
|
||||
@Parcelize
|
||||
data class ParcelableSize(val value: Size) : Parcelable
|
||||
|
||||
@Parcelize
|
||||
data class ParcelableSizeF(val value: SizeF) : Parcelable
|
||||
|
||||
@Parcelize
|
||||
data class ParcelableArray(val value: Array<Parcelable?>) : Parcelable {
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (javaClass != other?.javaClass) return false
|
||||
|
||||
other as ParcelableArray
|
||||
|
||||
if (!value.contentEquals(other.value)) return false
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
return value.contentHashCode()
|
||||
}
|
||||
}
|
||||
|
||||
@Parcelize
|
||||
data class ParcelableList(val value: List<Parcelable?>) : Parcelable
|
||||
|
||||
@SuppressLint("Recycle")
|
||||
inline fun <T> 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()
|
||||
}
|
||||
inline fun <reified T : Parcelable> ByteArray.toParcelable(classLoader: ClassLoader? = T::class.java.classLoader) =
|
||||
useParcel { p ->
|
||||
p.unmarshall(this, 0, size)
|
||||
p.setDataPosition(0)
|
||||
p.readParcelable<T>(classLoader)
|
||||
}
|
||||
|
||||
// Stream closed caused in NullOutputStream
|
||||
val IOException.isEBADF get() = message == "Stream closed" || (cause as? ErrnoException)?.errno == OsConstants.EBADF
|
||||
@@ -10,6 +10,7 @@ import androidx.core.os.bundleOf
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.setFragmentResult
|
||||
import androidx.fragment.app.setFragmentResultListener
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import kotlinx.parcelize.Parcelize
|
||||
|
||||
/**
|
||||
@@ -44,7 +45,7 @@ abstract class AlertDialogFragment<Arg : Parcelable, Ret : Parcelable> :
|
||||
fun key(resultKey: String = javaClass.name) = args().putString(KEY_RESULT, resultKey)
|
||||
|
||||
override fun onCreateDialog(savedInstanceState: Bundle?): AlertDialog =
|
||||
AlertDialog.Builder(requireContext()).also { it.prepare(this) }.create()
|
||||
MaterialAlertDialogBuilder(requireContext()).also { it.prepare(this) }.create()
|
||||
|
||||
override fun onClick(dialog: DialogInterface?, which: Int) {
|
||||
setFragmentResult(resultKey ?: return, Bundle().apply {
|
||||
|
||||
@@ -2,18 +2,20 @@ package be.mygod.vpnhotspot
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.app.Application
|
||||
import android.content.ActivityNotFoundException
|
||||
import android.content.ClipboardManager
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.res.Configuration
|
||||
import android.location.LocationManager
|
||||
import android.os.Build
|
||||
import android.provider.Settings
|
||||
import android.util.Log
|
||||
import android.widget.Toast
|
||||
import androidx.annotation.Size
|
||||
import androidx.browser.customtabs.CustomTabColorSchemeParams
|
||||
import androidx.browser.customtabs.CustomTabsIntent
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.content.getSystemService
|
||||
import androidx.core.provider.FontRequest
|
||||
import androidx.emoji.text.EmojiCompat
|
||||
import androidx.emoji.text.FontRequestEmojiCompatConfig
|
||||
import androidx.preference.PreferenceManager
|
||||
import be.mygod.librootkotlinx.NoShellException
|
||||
import be.mygod.vpnhotspot.net.DhcpWorkaround
|
||||
@@ -21,6 +23,7 @@ import be.mygod.vpnhotspot.room.AppDatabase
|
||||
import be.mygod.vpnhotspot.root.RootManager
|
||||
import be.mygod.vpnhotspot.util.DeviceStorageApp
|
||||
import be.mygod.vpnhotspot.util.Services
|
||||
import be.mygod.vpnhotspot.widget.SmartSnackbar
|
||||
import com.google.firebase.analytics.ktx.ParametersBuilder
|
||||
import com.google.firebase.analytics.ktx.analytics
|
||||
import com.google.firebase.crashlytics.FirebaseCrashlytics
|
||||
@@ -39,15 +42,15 @@ class App : Application() {
|
||||
lateinit var app: App
|
||||
}
|
||||
|
||||
@SuppressLint("RestrictedApi")
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
app = this
|
||||
if (Build.VERSION.SDK_INT >= 24) @SuppressLint("RestrictedApi") {
|
||||
deviceStorage = DeviceStorageApp(this)
|
||||
// alternative to PreferenceManager.getDefaultSharedPreferencesName(this)
|
||||
deviceStorage.moveSharedPreferencesFrom(this, PreferenceManager(this).sharedPreferencesName)
|
||||
deviceStorage.moveDatabaseFrom(this, AppDatabase.DB_NAME)
|
||||
} else deviceStorage = this
|
||||
BootReceiver.migrateIfNecessary()
|
||||
Services.init { this }
|
||||
|
||||
// overhead of debug mode is minimal: https://github.com/Kotlin/kotlinx.coroutines/blob/f528898/docs/debugging.md#debug-mode
|
||||
@@ -57,7 +60,7 @@ class App : Application() {
|
||||
"REL" -> { }
|
||||
else -> FirebaseCrashlytics.getInstance().apply {
|
||||
setCustomKey("codename", codename)
|
||||
if (Build.VERSION.SDK_INT >= 23) setCustomKey("preview_sdk", Build.VERSION.PREVIEW_SDK_INT)
|
||||
setCustomKey("preview_sdk", Build.VERSION.PREVIEW_SDK_INT)
|
||||
}
|
||||
}
|
||||
Timber.plant(object : Timber.DebugTree() {
|
||||
@@ -78,18 +81,6 @@ class App : Application() {
|
||||
}
|
||||
})
|
||||
ServiceNotification.updateNotificationChannels()
|
||||
EmojiCompat.init(FontRequestEmojiCompatConfig(deviceStorage, FontRequest(
|
||||
"com.google.android.gms.fonts",
|
||||
"com.google.android.gms",
|
||||
"Noto Color Emoji Compat",
|
||||
R.array.com_google_android_gms_fonts_certs)).apply {
|
||||
setEmojiSpanIndicatorEnabled(BuildConfig.DEBUG)
|
||||
registerInitCallback(object : EmojiCompat.InitCallback() {
|
||||
override fun onInitialized() = Timber.d("EmojiCompat initialized")
|
||||
override fun onFailed(throwable: Throwable?) = Timber.d(throwable)
|
||||
})
|
||||
})
|
||||
EBegFragment.init()
|
||||
if (DhcpWorkaround.shouldEnable) DhcpWorkaround.enable(true)
|
||||
}
|
||||
|
||||
@@ -116,6 +107,21 @@ class App : Application() {
|
||||
Firebase.analytics.logEvent(event, builder.bundle)
|
||||
}
|
||||
|
||||
/**
|
||||
* LOH also requires location to be turned on. So does p2p for some reason. Source:
|
||||
* https://android.googlesource.com/platform/frameworks/opt/net/wifi/+/53e0284/service/java/com/android/server/wifi/WifiServiceImpl.java#1204
|
||||
* https://android.googlesource.com/platform/frameworks/opt/net/wifi/+/53e0284/service/java/com/android/server/wifi/WifiSettingsStore.java#228
|
||||
*/
|
||||
inline fun <reified T> startServiceWithLocation(context: Context) {
|
||||
if (Build.VERSION.SDK_INT < 33 && location?.isLocationEnabled != true) try {
|
||||
context.startActivity(Intent(Settings.ACTION_LOCATION_SOURCE_SETTINGS))
|
||||
Toast.makeText(context, R.string.tethering_location_off, Toast.LENGTH_LONG).show()
|
||||
} catch (e: ActivityNotFoundException) {
|
||||
app.logEvent("location_settings") { param("message", e.toString()) }
|
||||
SmartSnackbar.make(R.string.tethering_location_off).show()
|
||||
} else context.startForegroundService(Intent(context, T::class.java))
|
||||
}
|
||||
|
||||
lateinit var deviceStorage: Application
|
||||
val english by lazy {
|
||||
createConfigurationContext(Configuration(resources.configuration).apply {
|
||||
@@ -124,16 +130,17 @@ class App : Application() {
|
||||
}
|
||||
val pref by lazy { PreferenceManager.getDefaultSharedPreferences(deviceStorage) }
|
||||
val clipboard by lazy { getSystemService<ClipboardManager>()!! }
|
||||
val location by lazy { getSystemService<LocationManager>() }
|
||||
|
||||
val hasTouch by lazy { packageManager.hasSystemFeature("android.hardware.faketouch") }
|
||||
val customTabsIntent by lazy {
|
||||
CustomTabsIntent.Builder().apply {
|
||||
setColorScheme(CustomTabsIntent.COLOR_SCHEME_SYSTEM)
|
||||
setColorSchemeParams(CustomTabsIntent.COLOR_SCHEME_LIGHT, CustomTabColorSchemeParams.Builder().apply {
|
||||
setToolbarColor(ContextCompat.getColor(app, R.color.light_colorPrimary))
|
||||
setToolbarColor(resources.getColor(R.color.light_colorPrimary, theme))
|
||||
}.build())
|
||||
setColorSchemeParams(CustomTabsIntent.COLOR_SCHEME_DARK, CustomTabColorSchemeParams.Builder().apply {
|
||||
setToolbarColor(ContextCompat.getColor(app, R.color.dark_colorPrimary))
|
||||
setToolbarColor(resources.getColor(R.color.dark_colorPrimary, theme))
|
||||
}.build())
|
||||
}.build()
|
||||
}
|
||||
|
||||
@@ -5,31 +5,114 @@ import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import androidx.core.content.ContextCompat
|
||||
import android.os.Parcelable
|
||||
import be.mygod.librootkotlinx.toByteArray
|
||||
import be.mygod.librootkotlinx.toParcelable
|
||||
import be.mygod.vpnhotspot.App.Companion.app
|
||||
import be.mygod.vpnhotspot.util.Services
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import timber.log.Timber
|
||||
import java.io.DataInputStream
|
||||
import java.io.DataOutputStream
|
||||
import java.io.File
|
||||
import java.io.FileNotFoundException
|
||||
|
||||
class BootReceiver : BroadcastReceiver() {
|
||||
companion object {
|
||||
const val KEY = "service.autoStart"
|
||||
|
||||
private val componentName by lazy { ComponentName(app, BootReceiver::class.java) }
|
||||
var enabled: Boolean
|
||||
private var enabled: Boolean
|
||||
get() = app.packageManager.getComponentEnabledSetting(componentName) ==
|
||||
PackageManager.COMPONENT_ENABLED_STATE_ENABLED
|
||||
set(value) = app.packageManager.setComponentEnabledSetting(componentName,
|
||||
if (value) PackageManager.COMPONENT_ENABLED_STATE_ENABLED
|
||||
else PackageManager.COMPONENT_ENABLED_STATE_DISABLED, PackageManager.DONT_KILL_APP)
|
||||
|
||||
private var started = false
|
||||
private val userEnabled get() = app.pref.getBoolean(KEY, false)
|
||||
fun onUserSettingUpdated(shouldStart: Boolean) {
|
||||
enabled = shouldStart && try {
|
||||
config
|
||||
} catch (e: Exception) {
|
||||
Timber.w(e)
|
||||
null
|
||||
}?.startables?.isEmpty() == false
|
||||
}
|
||||
private fun onConfigUpdated(isNotEmpty: Boolean) {
|
||||
enabled = isNotEmpty && userEnabled
|
||||
}
|
||||
|
||||
private const val FILENAME = "bootconfig"
|
||||
private val configFile by lazy { File(app.deviceStorage.noBackupFilesDir, FILENAME) }
|
||||
private val config: Config? get() = try {
|
||||
DataInputStream(configFile.inputStream()).use { it.readBytes().toParcelable() }
|
||||
} catch (_: FileNotFoundException) {
|
||||
null
|
||||
}
|
||||
private fun updateConfig(work: Config.() -> Unit) = synchronized(BootReceiver) {
|
||||
val config = try {
|
||||
config
|
||||
} catch (e: Exception) {
|
||||
Timber.i("Boot config corrupted", e)
|
||||
null
|
||||
} ?: Config()
|
||||
config.work()
|
||||
DataOutputStream(configFile.outputStream()).use { it.write(config.toByteArray()) }
|
||||
config
|
||||
}
|
||||
|
||||
fun add(key: String, value: Startable) = try {
|
||||
updateConfig { startables[key] = value }
|
||||
onConfigUpdated(true)
|
||||
} catch (e: Exception) {
|
||||
Timber.w(e)
|
||||
}
|
||||
fun delete(key: String) = try {
|
||||
onConfigUpdated(updateConfig { startables.remove(key) }.startables.isNotEmpty())
|
||||
} catch (e: Exception) {
|
||||
Timber.w(e)
|
||||
}
|
||||
inline fun <reified T> add(value: Startable) = add(T::class.java.name, value)
|
||||
inline fun <reified T> delete() = delete(T::class.java.name)
|
||||
|
||||
fun migrateIfNecessary() {
|
||||
val oldFile = File(app.noBackupFilesDir, FILENAME)
|
||||
if (oldFile.canRead()) try {
|
||||
if (!configFile.exists()) oldFile.copyTo(configFile)
|
||||
if (!oldFile.delete()) oldFile.deleteOnExit()
|
||||
} catch (e: Exception) {
|
||||
Timber.w(e)
|
||||
}
|
||||
}
|
||||
private var started = false
|
||||
private fun startIfNecessary() {
|
||||
if (started) return
|
||||
val config = try {
|
||||
synchronized(BootReceiver) { config }
|
||||
} catch (e: Exception) {
|
||||
Timber.w(e)
|
||||
null
|
||||
}
|
||||
if (config == null || config.startables.isEmpty()) {
|
||||
enabled = false
|
||||
} else for (startable in config.startables.values) startable.start(app)
|
||||
started = true
|
||||
}
|
||||
fun startIfEnabled() {
|
||||
if (!started && userEnabled) startIfNecessary()
|
||||
}
|
||||
}
|
||||
|
||||
interface Startable : Parcelable {
|
||||
fun start(context: Context)
|
||||
}
|
||||
|
||||
@Parcelize
|
||||
private data class Config(var startables: MutableMap<String, Startable> = mutableMapOf()) : Parcelable
|
||||
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
if (started) return
|
||||
when (intent.action) {
|
||||
Intent.ACTION_BOOT_COMPLETED, Intent.ACTION_LOCKED_BOOT_COMPLETED -> started = true
|
||||
else -> return
|
||||
}
|
||||
if (Services.p2p != null) {
|
||||
ContextCompat.startForegroundService(context, Intent(context, RepeaterService::class.java))
|
||||
Intent.ACTION_BOOT_COMPLETED, Intent.ACTION_LOCKED_BOOT_COMPLETED, Intent.ACTION_MY_PACKAGE_REPLACED -> {
|
||||
if (userEnabled) startIfNecessary() else enabled = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,26 +23,30 @@ import timber.log.Timber
|
||||
*/
|
||||
class EBegFragment : AppCompatDialogFragment() {
|
||||
companion object : BillingClientStateListener, PurchasesUpdatedListener {
|
||||
private lateinit var billingClient: BillingClient
|
||||
|
||||
fun init() {
|
||||
billingClient = BillingClient.newBuilder(app).apply {
|
||||
private val billingClient by lazy {
|
||||
BillingClient.newBuilder(app).apply {
|
||||
enablePendingPurchases()
|
||||
}.setListener(this).build().also { it.startConnection(this) }
|
||||
}.setListener(this).build()
|
||||
}
|
||||
private var instance: EBegFragment? = null
|
||||
|
||||
override fun onBillingSetupFinished(billingResult: BillingResult) {
|
||||
if (billingResult.responseCode != BillingClient.BillingResponseCode.OK) {
|
||||
Timber.e("onBillingSetupFinished: ${billingResult.responseCode}")
|
||||
} else GlobalScope.launch(Dispatchers.Main.immediate) {
|
||||
val result = billingClient.queryPurchasesAsync(BillingClient.SkuType.INAPP)
|
||||
} else {
|
||||
instance?.onBillingConnected()
|
||||
GlobalScope.launch(Dispatchers.Main.immediate) {
|
||||
val result = billingClient.queryPurchasesAsync(QueryPurchasesParams.newBuilder().apply {
|
||||
setProductType(BillingClient.ProductType.INAPP)
|
||||
}.build())
|
||||
onPurchasesUpdated(result.billingResult, result.purchasesList)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onBillingServiceDisconnected() {
|
||||
Timber.e("onBillingServiceDisconnected")
|
||||
billingClient.startConnection(this)
|
||||
if (instance != null) billingClient.startConnection(this)
|
||||
}
|
||||
|
||||
override fun onPurchasesUpdated(billingResult: BillingResult, purchases: List<Purchase>?) {
|
||||
@@ -64,12 +68,12 @@ class EBegFragment : AppCompatDialogFragment() {
|
||||
}
|
||||
|
||||
private lateinit var binding: FragmentEbegBinding
|
||||
private var skus: List<SkuDetails>? = null
|
||||
private var productDetails: List<ProductDetails>? = null
|
||||
set(value) {
|
||||
field = value
|
||||
binding.donationsGoogleAndroidMarketSpinner.apply {
|
||||
val adapter = ArrayAdapter(context ?: return, android.R.layout.simple_spinner_item,
|
||||
value?.map { it.price } ?: listOf("…"))
|
||||
value?.map { it.oneTimePurchaseOfferDetails?.formattedPrice } ?: listOf("…"))
|
||||
adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item)
|
||||
setAdapter(adapter)
|
||||
}
|
||||
@@ -82,27 +86,46 @@ class EBegFragment : AppCompatDialogFragment() {
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
dialog!!.setTitle(R.string.settings_misc_donate)
|
||||
viewLifecycleOwner.lifecycleScope.launchWhenCreated {
|
||||
billingClient.querySkuDetails(SkuDetailsParams.newBuilder().apply {
|
||||
setSkusList(listOf("donate001", "donate002", "donate005", "donate010", "donate020", "donate050",
|
||||
"donate100", "donate200", "donatemax"))
|
||||
setType(BillingClient.SkuType.INAPP)
|
||||
}.build()).apply {
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
binding.donationsGoogleAndroidMarketDonateButton.setOnClickListener {
|
||||
val sku = skus?.getOrNull(binding.donationsGoogleAndroidMarketSpinner.selectedItemPosition)
|
||||
if (sku != null) billingClient.launchBillingFlow(requireActivity(), BillingFlowParams.newBuilder().apply {
|
||||
setSkuDetails(sku)
|
||||
val product = productDetails?.getOrNull(binding.donationsGoogleAndroidMarketSpinner.selectedItemPosition)
|
||||
if (product != null) billingClient.launchBillingFlow(requireActivity(), BillingFlowParams.newBuilder().apply {
|
||||
setProductDetailsParamsList(listOf(BillingFlowParams.ProductDetailsParams.newBuilder().apply {
|
||||
setProductDetails(product)
|
||||
}.build()))
|
||||
}.build()) else SmartSnackbar.make(R.string.donations__google_android_market_not_supported).show()
|
||||
}
|
||||
@Suppress("ConstantConditionIf")
|
||||
if (BuildConfig.DONATIONS) (binding.donationsMoreStub.inflate() as Button).setOnClickListener {
|
||||
requireContext().launchUrl("https://mygod.be/donate/")
|
||||
}
|
||||
}
|
||||
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
instance = this
|
||||
billingClient.startConnection(EBegFragment)
|
||||
}
|
||||
|
||||
private fun onBillingConnected() = viewLifecycleOwner.lifecycleScope.launch {
|
||||
billingClient.queryProductDetails(QueryProductDetailsParams.newBuilder().apply {
|
||||
setProductList(listOf(
|
||||
"donate001", "donate002", "donate005", "donate010", "donate020", "donate050",
|
||||
"donate100", "donate200", "donatemax",
|
||||
).map {
|
||||
QueryProductDetailsParams.Product.newBuilder().apply {
|
||||
setProductId(it)
|
||||
setProductType(BillingClient.ProductType.INAPP)
|
||||
}.build()
|
||||
})
|
||||
}.build()).apply {
|
||||
if (billingResult.responseCode != BillingClient.BillingResponseCode.OK) {
|
||||
Timber.e("queryProductDetails: ${billingResult.responseCode}")
|
||||
SmartSnackbar.make(R.string.donations__google_android_market_not_supported).show()
|
||||
} else productDetails = productDetailsList
|
||||
}
|
||||
}
|
||||
|
||||
override fun onStop() {
|
||||
instance = null
|
||||
super.onStop()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ abstract class IpNeighbourMonitoringService : Service(), IpNeighbourMonitor.Call
|
||||
this.neighbours = neighbours
|
||||
updateNotification()
|
||||
}
|
||||
protected fun updateNotification() {
|
||||
protected open fun updateNotification() {
|
||||
val sizeLookup = neighbours.groupBy { it.dev }.mapValues { (_, neighbours) ->
|
||||
neighbours
|
||||
.filter { it.ip is Inet4Address && it.state == IpNeighbour.State.VALID }
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package be.mygod.vpnhotspot
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.net.wifi.WifiManager
|
||||
@@ -8,14 +9,12 @@ import androidx.annotation.RequiresApi
|
||||
import be.mygod.librootkotlinx.RootServer
|
||||
import be.mygod.vpnhotspot.App.Companion.app
|
||||
import be.mygod.vpnhotspot.net.IpNeighbour
|
||||
import be.mygod.vpnhotspot.net.TetherType
|
||||
import be.mygod.vpnhotspot.net.TetheringManager
|
||||
import be.mygod.vpnhotspot.net.TetheringManager.localOnlyTetheredIfaces
|
||||
import be.mygod.vpnhotspot.net.monitor.IpNeighbourMonitor
|
||||
import be.mygod.vpnhotspot.net.monitor.TetherTimeoutMonitor
|
||||
import be.mygod.vpnhotspot.net.wifi.SoftApConfigurationCompat
|
||||
import be.mygod.vpnhotspot.net.wifi.SoftApConfigurationCompat.Companion.toCompat
|
||||
import be.mygod.vpnhotspot.net.wifi.WifiApManager
|
||||
import be.mygod.vpnhotspot.net.wifi.WifiApManager.wifiApState
|
||||
import be.mygod.vpnhotspot.root.LocalOnlyHotspotCallbacks
|
||||
import be.mygod.vpnhotspot.root.RootManager
|
||||
import be.mygod.vpnhotspot.root.WifiApCommands
|
||||
@@ -24,10 +23,10 @@ import be.mygod.vpnhotspot.util.StickyEvent1
|
||||
import be.mygod.vpnhotspot.util.broadcastReceiver
|
||||
import be.mygod.vpnhotspot.widget.SmartSnackbar
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import timber.log.Timber
|
||||
import java.net.Inet4Address
|
||||
|
||||
@RequiresApi(26)
|
||||
class LocalOnlyHotspotService : IpNeighbourMonitoringService(), CoroutineScope {
|
||||
companion object {
|
||||
const val KEY_USE_SYSTEM = "service.tempHotspot.useSystem"
|
||||
@@ -50,7 +49,15 @@ class LocalOnlyHotspotService : IpNeighbourMonitoringService(), CoroutineScope {
|
||||
null -> return // stopped
|
||||
"" -> WifiApManager.cancelLocalOnlyHotspotRequest()
|
||||
}
|
||||
reservation?.close() ?: stopService()
|
||||
reservation?.close()
|
||||
stopService()
|
||||
}
|
||||
}
|
||||
|
||||
@Parcelize
|
||||
class Starter : BootReceiver.Startable {
|
||||
override fun start(context: Context) {
|
||||
context.startForegroundService(Intent(context, LocalOnlyHotspotService::class.java))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -82,16 +89,17 @@ class LocalOnlyHotspotService : IpNeighbourMonitoringService(), CoroutineScope {
|
||||
private val binder = Binder()
|
||||
private var reservation: Reservation? = null
|
||||
set(value) {
|
||||
if (value == null) field?.close()
|
||||
field = value
|
||||
if (value != null && !receiverRegistered) {
|
||||
timeoutMonitor?.close()
|
||||
timeoutMonitor = null
|
||||
if (value != null) {
|
||||
val configuration = binder.configuration
|
||||
if (Build.VERSION.SDK_INT < 30 && configuration!!.isAutoShutdownEnabled) {
|
||||
timeoutMonitor = TetherTimeoutMonitor(configuration.shutdownTimeoutMillis, coroutineContext) {
|
||||
value.close()
|
||||
}
|
||||
}
|
||||
registerReceiver(receiver, IntentFilter(TetheringManager.ACTION_TETHER_STATE_CHANGED))
|
||||
receiverRegistered = true
|
||||
}
|
||||
}
|
||||
private fun onFrameworkFailed(reason: Int) {
|
||||
@@ -116,33 +124,30 @@ class LocalOnlyHotspotService : IpNeighbourMonitoringService(), CoroutineScope {
|
||||
/**
|
||||
* Writes and critical reads to routingManager should be protected with this context.
|
||||
*/
|
||||
private val dispatcher = newSingleThreadContext("LocalOnlyHotspotService")
|
||||
private val dispatcher = Dispatchers.IO.limitedParallelism(1)
|
||||
override val coroutineContext = dispatcher + Job()
|
||||
private var routingManager: RoutingManager? = null
|
||||
private var timeoutMonitor: TetherTimeoutMonitor? = null
|
||||
private var receiverRegistered = false
|
||||
private val receiver = broadcastReceiver { _, intent ->
|
||||
val ifaces = (intent.localOnlyTetheredIfaces ?: return@broadcastReceiver).filter {
|
||||
TetherType.ofInterface(it) != TetherType.WIFI_P2P
|
||||
}
|
||||
Timber.d("onTetherStateChangedLocked: $ifaces")
|
||||
check(ifaces.size <= 1)
|
||||
val iface = ifaces.singleOrNull()
|
||||
binder.iface = iface
|
||||
if (iface.isNullOrEmpty()) stopService() else launch {
|
||||
val routingManager = routingManager
|
||||
if (routingManager == null) {
|
||||
this@LocalOnlyHotspotService.routingManager = RoutingManager.LocalOnly(this@LocalOnlyHotspotService,
|
||||
iface).apply { start() }
|
||||
IpNeighbourMonitor.registerCallback(this@LocalOnlyHotspotService)
|
||||
} else check(iface == routingManager.downstream)
|
||||
}
|
||||
}
|
||||
override val activeIfaces get() = binder.iface.let { if (it.isNullOrEmpty()) emptyList() else listOf(it) }
|
||||
|
||||
private var lastState: Triple<Int, String?, Int>? = null
|
||||
private val receiver = broadcastReceiver { _, intent -> updateState(intent) }
|
||||
private var receiverRegistered = false
|
||||
private fun updateState(intent: Intent) {
|
||||
// based on: https://android.googlesource.com/platform/packages/services/Car/+/72c71d2/service/src/com/android/car/CarProjectionService.java#160
|
||||
lastState = Triple(intent.wifiApState, intent.getStringExtra(WifiApManager.EXTRA_WIFI_AP_INTERFACE_NAME),
|
||||
intent.getIntExtra(WifiApManager.EXTRA_WIFI_AP_FAILURE_REASON, 0))
|
||||
}
|
||||
private fun unregisterStateReceiver() {
|
||||
if (!receiverRegistered) return
|
||||
receiverRegistered = false
|
||||
unregisterReceiver(receiver)
|
||||
}
|
||||
|
||||
override fun onBind(intent: Intent?) = binder
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
BootReceiver.startIfEnabled()
|
||||
if (binder.iface != null) return START_STICKY
|
||||
binder.iface = ""
|
||||
updateNotification() // show invisible foreground notification to avoid being killed
|
||||
@@ -150,6 +155,11 @@ class LocalOnlyHotspotService : IpNeighbourMonitoringService(), CoroutineScope {
|
||||
return START_STICKY
|
||||
}
|
||||
private suspend fun doStart() {
|
||||
if (!receiverRegistered) {
|
||||
receiverRegistered = true
|
||||
registerReceiver(receiver, IntentFilter(WifiApManager.WIFI_AP_STATE_CHANGED_ACTION))
|
||||
?.let(this@LocalOnlyHotspotService::updateState)
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= 30 && app.pref.getBoolean(KEY_USE_SYSTEM, false)) try {
|
||||
RootManager.use {
|
||||
Root(it).apply {
|
||||
@@ -170,6 +180,24 @@ class LocalOnlyHotspotService : IpNeighbourMonitoringService(), CoroutineScope {
|
||||
if (reservation == null) onFailed(-2) else {
|
||||
this@LocalOnlyHotspotService.reservation = Framework(reservation)
|
||||
}
|
||||
registerReceiver(null, IntentFilter(WifiApManager.WIFI_AP_STATE_CHANGED_ACTION))
|
||||
?.let(this@LocalOnlyHotspotService::updateState) // attempt to update again
|
||||
val state = lastState
|
||||
unregisterStateReceiver()
|
||||
checkNotNull(state) { "Failed to obtain latest AP state" }
|
||||
val iface = state.second
|
||||
if (state.first != WifiApManager.WIFI_AP_STATE_ENABLED || iface.isNullOrEmpty()) {
|
||||
if (state.first == WifiApManager.WIFI_AP_STATE_FAILED) {
|
||||
SmartSnackbar.make(getString(R.string.tethering_temp_hotspot_failure,
|
||||
WifiApManager.failureReasonLookup(state.third))).show()
|
||||
}
|
||||
return stopService()
|
||||
}
|
||||
binder.iface = iface
|
||||
BootReceiver.add<LocalOnlyHotspotService>(Starter())
|
||||
check(routingManager == null)
|
||||
routingManager = RoutingManager.LocalOnly(this@LocalOnlyHotspotService, iface).apply { start() }
|
||||
IpNeighbourMonitor.registerCallback(this@LocalOnlyHotspotService)
|
||||
}
|
||||
override fun onStopped() {
|
||||
reservation = null
|
||||
@@ -191,7 +219,7 @@ class LocalOnlyHotspotService : IpNeighbourMonitoringService(), CoroutineScope {
|
||||
|
||||
override fun onIpNeighbourAvailable(neighbours: Collection<IpNeighbour>) {
|
||||
super.onIpNeighbourAvailable(neighbours)
|
||||
if (Build.VERSION.SDK_INT >= 28) timeoutMonitor?.onClientsChanged(neighbours.none {
|
||||
timeoutMonitor?.onClientsChanged(neighbours.none {
|
||||
it.ip is Inet4Address && it.state == IpNeighbour.State.VALID
|
||||
})
|
||||
}
|
||||
@@ -203,6 +231,7 @@ class LocalOnlyHotspotService : IpNeighbourMonitoringService(), CoroutineScope {
|
||||
}
|
||||
|
||||
private fun stopService() {
|
||||
BootReceiver.delete<LocalOnlyHotspotService>()
|
||||
binder.iface = null
|
||||
unregisterReceiver()
|
||||
ServiceNotification.stopForeground(this)
|
||||
@@ -210,22 +239,14 @@ class LocalOnlyHotspotService : IpNeighbourMonitoringService(), CoroutineScope {
|
||||
}
|
||||
|
||||
private fun unregisterReceiver(exit: Boolean = false) {
|
||||
if (receiverRegistered) {
|
||||
unregisterReceiver(receiver)
|
||||
IpNeighbourMonitor.unregisterCallback(this)
|
||||
if (Build.VERSION.SDK_INT >= 28) {
|
||||
timeoutMonitor?.close()
|
||||
timeoutMonitor = null
|
||||
}
|
||||
receiverRegistered = false
|
||||
}
|
||||
launch {
|
||||
routingManager?.stop()
|
||||
routingManager = null
|
||||
if (exit) {
|
||||
cancel()
|
||||
dispatcher.close()
|
||||
}
|
||||
unregisterStateReceiver()
|
||||
if (exit) cancel()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,28 +4,60 @@ import android.os.Bundle
|
||||
import android.view.MenuItem
|
||||
import androidx.activity.viewModels
|
||||
import androidx.appcompat.app.AppCompatActivity
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.graphics.Insets
|
||||
import androidx.core.view.ViewCompat
|
||||
import androidx.core.view.WindowCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.lifecycle.repeatOnLifecycle
|
||||
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.IpNeighbour
|
||||
import be.mygod.vpnhotspot.net.wifi.WifiDoubleLock
|
||||
import be.mygod.vpnhotspot.util.AppUpdate
|
||||
import be.mygod.vpnhotspot.util.ServiceForegroundConnector
|
||||
import be.mygod.vpnhotspot.util.Services
|
||||
import be.mygod.vpnhotspot.util.UpdateChecker
|
||||
import be.mygod.vpnhotspot.widget.SmartSnackbar
|
||||
import com.google.android.material.badge.BadgeDrawable
|
||||
import com.google.android.material.navigation.NavigationBarView
|
||||
import kotlinx.coroutines.launch
|
||||
import timber.log.Timber
|
||||
import java.net.Inet4Address
|
||||
import java.util.concurrent.CancellationException
|
||||
|
||||
class MainActivity : AppCompatActivity(), NavigationBarView.OnItemSelectedListener {
|
||||
lateinit var binding: ActivityMainBinding
|
||||
private lateinit var updateItem: MenuItem
|
||||
private lateinit var updateBadge: BadgeDrawable
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
WindowCompat.setDecorFitsSystemWindows(window, false)
|
||||
ViewCompat.setOnApplyWindowInsetsListener(findViewById(android.R.id.content)) { view, insets ->
|
||||
val statusBarInsets = insets.getInsets(WindowInsetsCompat.Type.statusBars())
|
||||
view.setPadding(statusBarInsets.left, statusBarInsets.top, statusBarInsets.right, statusBarInsets.bottom)
|
||||
WindowInsetsCompat.Builder(insets).apply {
|
||||
setInsets(WindowInsetsCompat.Type.statusBars(), Insets.NONE)
|
||||
}.build()
|
||||
}
|
||||
binding = ActivityMainBinding.inflate(layoutInflater)
|
||||
setContentView(binding.root)
|
||||
binding.navigation.setOnItemSelectedListener(this)
|
||||
val badge = binding.navigation.getOrCreateBadge(R.id.navigation_clients).apply {
|
||||
backgroundColor = resources.getColor(R.color.colorSecondary, theme)
|
||||
badgeTextColor = resources.getColor(androidx.appcompat.R.color.primary_text_default_material_light, theme)
|
||||
}
|
||||
updateItem = binding.navigation.menu.findItem(R.id.navigation_update)
|
||||
updateItem.isCheckable = false
|
||||
updateBadge = binding.navigation.getOrCreateBadge(R.id.navigation_update).apply {
|
||||
backgroundColor = resources.getColor(R.color.colorSecondary, theme)
|
||||
badgeTextColor = resources.getColor(androidx.appcompat.R.color.primary_text_default_material_light, theme)
|
||||
}
|
||||
if (savedInstanceState == null) displayFragment(TetheringFragment())
|
||||
val model by viewModels<ClientViewModel>()
|
||||
lifecycle.addObserver(model)
|
||||
@@ -34,38 +66,69 @@ class MainActivity : AppCompatActivity(), NavigationBarView.OnItemSelectedListen
|
||||
val count = clients.count {
|
||||
it.ip.any { (ip, state) -> ip is Inet4Address && state == IpNeighbour.State.VALID }
|
||||
}
|
||||
if (count > 0) binding.navigation.getOrCreateBadge(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 = count
|
||||
} else binding.navigation.removeBadge(R.id.navigation_clients)
|
||||
badge.isVisible = count > 0
|
||||
badge.number = count
|
||||
}
|
||||
SmartSnackbar.Register(binding.fragmentHolder)
|
||||
WifiDoubleLock.ActivityListener(this)
|
||||
lifecycleScope.launch {
|
||||
BootReceiver.startIfEnabled()
|
||||
repeatOnLifecycle(Lifecycle.State.STARTED) {
|
||||
onAppUpdateAvailable(null)
|
||||
try {
|
||||
UpdateChecker.check().collect(this@MainActivity::onAppUpdateAvailable)
|
||||
} catch (_: CancellationException) {
|
||||
} catch (e: AppUpdate.IgnoredException) {
|
||||
Timber.d(e)
|
||||
} catch (e: Exception) {
|
||||
Timber.w(e)
|
||||
SmartSnackbar.make(e).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var lastUpdate: AppUpdate? = null
|
||||
private fun onAppUpdateAvailable(update: AppUpdate?) {
|
||||
lastUpdate = update
|
||||
updateItem.isVisible = update != null
|
||||
if (update == null) {
|
||||
updateItem.isEnabled = false
|
||||
return
|
||||
}
|
||||
updateItem.isEnabled = update.downloaded != false
|
||||
updateItem.setIcon(when (update.downloaded) {
|
||||
null -> R.drawable.ic_action_update
|
||||
false -> R.drawable.ic_file_downloading
|
||||
true -> R.drawable.ic_action_autorenew
|
||||
})
|
||||
updateItem.title = update.message ?: getText(R.string.title_update)
|
||||
updateBadge.isVisible = when (val days = update.stalenessDays) {
|
||||
null -> false
|
||||
else -> {
|
||||
if (days > 0) updateBadge.number = days else updateBadge.clearNumber()
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onNavigationItemSelected(item: MenuItem) = when (item.itemId) {
|
||||
R.id.navigation_clients -> {
|
||||
if (!item.isChecked) {
|
||||
item.isChecked = true
|
||||
displayFragment(ClientsFragment())
|
||||
}
|
||||
true
|
||||
}
|
||||
R.id.navigation_tethering -> {
|
||||
if (!item.isChecked) {
|
||||
item.isChecked = true
|
||||
displayFragment(TetheringFragment())
|
||||
}
|
||||
true
|
||||
}
|
||||
R.id.navigation_settings -> {
|
||||
if (!item.isChecked) {
|
||||
item.isChecked = true
|
||||
displayFragment(SettingsPreferenceFragment())
|
||||
}
|
||||
true
|
||||
}
|
||||
R.id.navigation_update -> {
|
||||
lastUpdate!!.updateForResult(this, 1)
|
||||
false
|
||||
}
|
||||
else -> false
|
||||
}
|
||||
|
||||
|
||||
@@ -3,9 +3,13 @@ package be.mygod.vpnhotspot
|
||||
import android.annotation.SuppressLint
|
||||
import android.annotation.TargetApi
|
||||
import android.app.Service
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.SharedPreferences
|
||||
import android.content.pm.PackageManager
|
||||
import android.location.LocationManager
|
||||
import android.net.MacAddress
|
||||
import android.net.wifi.ScanResult
|
||||
import android.net.wifi.WpsInfo
|
||||
import android.net.wifi.p2p.*
|
||||
import android.os.Build
|
||||
@@ -16,19 +20,27 @@ import androidx.annotation.StringRes
|
||||
import androidx.core.content.edit
|
||||
import be.mygod.vpnhotspot.App.Companion.app
|
||||
import be.mygod.vpnhotspot.net.MacAddressCompat
|
||||
import be.mygod.vpnhotspot.net.MacAddressCompat.Companion.toLong
|
||||
import be.mygod.vpnhotspot.net.monitor.TetherTimeoutMonitor
|
||||
import be.mygod.vpnhotspot.net.wifi.SoftApConfigurationCompat
|
||||
import be.mygod.vpnhotspot.net.wifi.VendorElements
|
||||
import be.mygod.vpnhotspot.net.wifi.WifiP2pManagerHelper
|
||||
import be.mygod.vpnhotspot.net.wifi.WifiP2pManagerHelper.deletePersistentGroup
|
||||
import be.mygod.vpnhotspot.net.wifi.WifiP2pManagerHelper.requestConnectionInfo
|
||||
import be.mygod.vpnhotspot.net.wifi.WifiP2pManagerHelper.requestDeviceAddress
|
||||
import be.mygod.vpnhotspot.net.wifi.WifiP2pManagerHelper.requestGroupInfo
|
||||
import be.mygod.vpnhotspot.net.wifi.WifiP2pManagerHelper.requestP2pState
|
||||
import be.mygod.vpnhotspot.net.wifi.WifiP2pManagerHelper.requestPersistentGroupInfo
|
||||
import be.mygod.vpnhotspot.net.wifi.WifiP2pManagerHelper.setVendorElements
|
||||
import be.mygod.vpnhotspot.net.wifi.WifiP2pManagerHelper.setWifiP2pChannels
|
||||
import be.mygod.vpnhotspot.net.wifi.WifiP2pManagerHelper.startWps
|
||||
import be.mygod.vpnhotspot.net.wifi.WifiSsidCompat
|
||||
import be.mygod.vpnhotspot.root.RepeaterCommands
|
||||
import be.mygod.vpnhotspot.root.RootManager
|
||||
import be.mygod.vpnhotspot.util.*
|
||||
import be.mygod.vpnhotspot.widget.SmartSnackbar
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import timber.log.Timber
|
||||
import java.lang.reflect.InvocationTargetException
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
@@ -42,12 +54,14 @@ class RepeaterService : Service(), CoroutineScope, WifiP2pManager.ChannelListene
|
||||
const val KEY_SAFE_MODE = "service.repeater.safeMode"
|
||||
|
||||
private const val KEY_NETWORK_NAME = "service.repeater.networkName"
|
||||
private const val KEY_NETWORK_NAME_HEX = "service.repeater.networkNameHex"
|
||||
private const val KEY_PASSPHRASE = "service.repeater.passphrase"
|
||||
private const val KEY_OPERATING_BAND = "service.repeater.band.v4"
|
||||
private const val KEY_OPERATING_CHANNEL = "service.repeater.oc.v3"
|
||||
private const val KEY_AUTO_SHUTDOWN = "service.repeater.autoShutdown"
|
||||
private const val KEY_SHUTDOWN_TIMEOUT = "service.repeater.shutdownTimeout"
|
||||
private const val KEY_DEVICE_ADDRESS = "service.repeater.mac"
|
||||
private const val KEY_VENDOR_ELEMENTS = "service.repeater.vendorElements"
|
||||
|
||||
var persistentSupported = false
|
||||
|
||||
@@ -61,17 +75,26 @@ class RepeaterService : Service(), CoroutineScope, WifiP2pManager.ChannelListene
|
||||
val safeModeConfigurable get() = Build.VERSION.SDK_INT >= 29 && hasP2pValidateName
|
||||
val safeMode get() = Build.VERSION.SDK_INT >= 29 &&
|
||||
(!hasP2pValidateName || app.pref.getBoolean(KEY_SAFE_MODE, true))
|
||||
private val mNetworkName by lazy { UnblockCentral.WifiP2pConfig_Builder_mNetworkName }
|
||||
@get:RequiresApi(29)
|
||||
private val mNetworkName by lazy @TargetApi(29) { UnblockCentral.WifiP2pConfig_Builder_mNetworkName }
|
||||
|
||||
var networkName: String?
|
||||
get() = app.pref.getString(KEY_NETWORK_NAME, null)
|
||||
set(value) = app.pref.edit { putString(KEY_NETWORK_NAME, value) }
|
||||
var networkName: WifiSsidCompat?
|
||||
get() = app.pref.getString(KEY_NETWORK_NAME, null).let { legacy ->
|
||||
if (legacy != null) WifiSsidCompat.fromUtf8Text(legacy).also {
|
||||
app.pref.edit {
|
||||
putString(KEY_NETWORK_NAME_HEX, it!!.hex)
|
||||
remove(KEY_NETWORK_NAME)
|
||||
}
|
||||
} else WifiSsidCompat.fromHex(app.pref.getString(KEY_NETWORK_NAME_HEX, null))
|
||||
}
|
||||
set(value) = app.pref.edit { putString(KEY_NETWORK_NAME_HEX, value?.hex) }
|
||||
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, SoftApConfigurationCompat.BAND_LEGACY)
|
||||
get() = app.pref.getInt(KEY_OPERATING_BAND, SoftApConfigurationCompat.BAND_LEGACY) and
|
||||
SoftApConfigurationCompat.BAND_LEGACY
|
||||
set(value) = app.pref.edit { putInt(KEY_OPERATING_BAND, value) }
|
||||
var operatingChannel: Int
|
||||
get() {
|
||||
@@ -85,17 +108,24 @@ class RepeaterService : Service(), CoroutineScope, WifiP2pManager.ChannelListene
|
||||
var shutdownTimeoutMillis: Long
|
||||
get() = app.pref.getLong(KEY_SHUTDOWN_TIMEOUT, 0)
|
||||
set(value) = app.pref.edit { putLong(KEY_SHUTDOWN_TIMEOUT, value) }
|
||||
var deviceAddress: MacAddressCompat?
|
||||
var deviceAddress: MacAddress?
|
||||
get() = try {
|
||||
MacAddressCompat(app.pref.getLong(KEY_DEVICE_ADDRESS, MacAddressCompat.ANY_ADDRESS.addr)).run {
|
||||
validate()
|
||||
if (this == MacAddressCompat.ANY_ADDRESS) null else this
|
||||
MacAddressCompat(app.pref.getLong(KEY_DEVICE_ADDRESS, 2)).run {
|
||||
require(addr and ((1L shl 48) - 1).inv() == 0L)
|
||||
if (addr == 2L) null else toPlatform()
|
||||
}
|
||||
} catch (e: IllegalArgumentException) {
|
||||
Timber.w(e)
|
||||
null
|
||||
}
|
||||
set(value) = app.pref.edit { putLong(KEY_DEVICE_ADDRESS, (value ?: MacAddressCompat.ANY_ADDRESS).addr) }
|
||||
set(value) = app.pref.edit {
|
||||
putLong(KEY_DEVICE_ADDRESS, (value ?: MacAddressCompat.ANY_ADDRESS).toLong())
|
||||
}
|
||||
@get:RequiresApi(33)
|
||||
@set:RequiresApi(33)
|
||||
var vendorElements: List<ScanResult.InformationElement>
|
||||
get() = VendorElements.deserialize(app.pref.getString(KEY_VENDOR_ELEMENTS, null))
|
||||
set(value) = app.pref.edit { putString(KEY_VENDOR_ELEMENTS, VendorElements.serialize(value)) }
|
||||
}
|
||||
|
||||
enum class Status {
|
||||
@@ -110,19 +140,17 @@ class RepeaterService : Service(), CoroutineScope, WifiP2pManager.ChannelListene
|
||||
set(value) {
|
||||
field = value
|
||||
groupChanged(value)
|
||||
if (Build.VERSION.SDK_INT >= 28) value?.clientList?.let {
|
||||
timeoutMonitor?.onClientsChanged(it.isEmpty())
|
||||
}
|
||||
value?.clientList?.let { timeoutMonitor?.onClientsChanged(it.isEmpty()) }
|
||||
}
|
||||
val groupChanged = StickyEvent1 { group }
|
||||
|
||||
suspend fun obtainDeviceAddress(): MacAddressCompat? {
|
||||
suspend fun obtainDeviceAddress(): MacAddress? {
|
||||
return if (Build.VERSION.SDK_INT >= 29) p2pManager.requestDeviceAddress(channel ?: return null) ?: try {
|
||||
RootManager.use { it.execute(RepeaterCommands.RequestDeviceAddress()) }
|
||||
} catch (e: Exception) {
|
||||
Timber.d(e)
|
||||
null
|
||||
}?.let { MacAddressCompat(it.value) } else lastMac?.let { MacAddressCompat.fromString(it) }
|
||||
} else lastMac?.let { MacAddress.fromString(it) }
|
||||
}
|
||||
|
||||
@SuppressLint("NewApi") // networkId is available since Android 4.2
|
||||
@@ -134,7 +162,7 @@ class RepeaterService : Service(), CoroutineScope, WifiP2pManager.ChannelListene
|
||||
val ownedGroups = filter {
|
||||
if (!it.isGroupOwner) return@filter false
|
||||
val address = try {
|
||||
MacAddressCompat.fromString(it.owner.deviceAddress)
|
||||
MacAddress.fromString(it.owner.deviceAddress)
|
||||
} catch (e: IllegalArgumentException) {
|
||||
Timber.w(e)
|
||||
return@filter true // assuming it was changed due to privacy
|
||||
@@ -199,6 +227,13 @@ class RepeaterService : Service(), CoroutineScope, WifiP2pManager.ChannelListene
|
||||
}
|
||||
}
|
||||
|
||||
@Parcelize
|
||||
class Starter : BootReceiver.Startable {
|
||||
override fun start(context: Context) {
|
||||
context.startForegroundService(Intent(context, RepeaterService::class.java))
|
||||
}
|
||||
}
|
||||
|
||||
private val p2pManager get() = Services.p2p!!
|
||||
private var channel: WifiP2pManager.Channel? = null
|
||||
private val binder = Binder()
|
||||
@@ -212,6 +247,9 @@ class RepeaterService : Service(), CoroutineScope, WifiP2pManager.ChannelListene
|
||||
WifiP2pManager.WIFI_P2P_CONNECTION_CHANGED_ACTION -> onP2pConnectionChanged(
|
||||
intent.getParcelableExtra(WifiP2pManager.EXTRA_WIFI_P2P_INFO),
|
||||
intent.getParcelableExtra(WifiP2pManager.EXTRA_WIFI_P2P_GROUP))
|
||||
LocationManager.MODE_CHANGED_ACTION -> @TargetApi(30) {
|
||||
onLocationModeChanged(intent.getBooleanExtra(LocationManager.EXTRA_LOCATION_ENABLED, false))
|
||||
}
|
||||
}
|
||||
}
|
||||
private val deviceListener = broadcastReceiver { _, intent ->
|
||||
@@ -222,7 +260,7 @@ class RepeaterService : Service(), CoroutineScope, WifiP2pManager.ChannelListene
|
||||
/**
|
||||
* Writes and critical reads to routingManager should be protected with this context.
|
||||
*/
|
||||
private val dispatcher = newSingleThreadContext("RepeaterService")
|
||||
private val dispatcher = Dispatchers.IO.limitedParallelism(1)
|
||||
override val coroutineContext = dispatcher + Job()
|
||||
private var routingManager: RoutingManager? = null
|
||||
private var persistNextGroup = false
|
||||
@@ -286,6 +324,36 @@ class RepeaterService : Service(), CoroutineScope, WifiP2pManager.ChannelListene
|
||||
} else SmartSnackbar.make(formatReason(R.string.repeater_set_oc_failure, reason)).show()
|
||||
} else SmartSnackbar.make(R.string.repeater_failure_disconnected).show()
|
||||
}
|
||||
@RequiresApi(33)
|
||||
private suspend fun setVendorElements(ve: List<ScanResult.InformationElement> = vendorElements) {
|
||||
val channel = channel
|
||||
if (channel != null) {
|
||||
val reason = try {
|
||||
p2pManager.setVendorElements(channel, ve) ?: return
|
||||
} catch (e: IllegalArgumentException) {
|
||||
SmartSnackbar.make(getString(R.string.repeater_set_vendor_elements_failure, e.message)).show()
|
||||
return
|
||||
} catch (e: UnsupportedOperationException) {
|
||||
if (ve.isNotEmpty()) {
|
||||
SmartSnackbar.make(getString(R.string.repeater_set_vendor_elements_failure, e.message)).show()
|
||||
}
|
||||
return
|
||||
}
|
||||
if (reason == WifiP2pManager.ERROR) {
|
||||
val rootReason = try {
|
||||
RootManager.use {
|
||||
if (deinitPending.getAndSet(false)) it.execute(RepeaterCommands.Deinit())
|
||||
it.execute(RepeaterCommands.SetVendorElements(ve))
|
||||
} ?: return
|
||||
} catch (e: Exception) {
|
||||
Timber.w(e)
|
||||
SmartSnackbar.make(e).show()
|
||||
return
|
||||
}
|
||||
SmartSnackbar.make(formatReason(R.string.repeater_set_vendor_elements_failure, rootReason.value)).show()
|
||||
} else SmartSnackbar.make(formatReason(R.string.repeater_set_vendor_elements_failure, reason)).show()
|
||||
} else SmartSnackbar.make(R.string.repeater_failure_disconnected).show()
|
||||
}
|
||||
|
||||
override fun onChannelDisconnected() {
|
||||
channel = null
|
||||
@@ -302,9 +370,34 @@ class RepeaterService : Service(), CoroutineScope, WifiP2pManager.ChannelListene
|
||||
}
|
||||
|
||||
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences?, key: String?) {
|
||||
if (!safeMode) when (key) {
|
||||
KEY_OPERATING_CHANNEL -> launch { setOperatingChannel() }
|
||||
KEY_SAFE_MODE -> deinitPending.set(true)
|
||||
when (key) {
|
||||
KEY_OPERATING_CHANNEL -> if (!safeMode) launch { setOperatingChannel() }
|
||||
KEY_VENDOR_ELEMENTS -> if (Build.VERSION.SDK_INT >= 33) launch { setVendorElements() }
|
||||
KEY_SAFE_MODE -> if (!safeMode) deinitPending.set(true)
|
||||
}
|
||||
}
|
||||
|
||||
private var p2pPoller: Job? = null
|
||||
@RequiresApi(30)
|
||||
private fun onLocationModeChanged(enabled: Boolean) = if (enabled) p2pPoller?.cancel() else {
|
||||
SmartSnackbar.make(R.string.repeater_location_off).apply {
|
||||
action(R.string.repeater_location_off_configure) {
|
||||
it.context.startActivity(Intent(Settings.ACTION_LOCATION_SOURCE_SETTINGS))
|
||||
}
|
||||
}.show()
|
||||
p2pPoller = launch(start = CoroutineStart.UNDISPATCHED) {
|
||||
while (true) {
|
||||
delay(1000)
|
||||
val channel = channel ?: return@launch
|
||||
coroutineScope {
|
||||
launch(start = CoroutineStart.UNDISPATCHED) {
|
||||
if (p2pManager.requestP2pState(channel) == WifiP2pManager.WIFI_P2P_STATE_DISABLED) cleanLocked()
|
||||
}
|
||||
val info = async(start = CoroutineStart.UNDISPATCHED) { p2pManager.requestConnectionInfo(channel) }
|
||||
val group = p2pManager.requestGroupInfo(channel)
|
||||
onP2pConnectionChanged(info.await(), group)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -312,25 +405,27 @@ class RepeaterService : Service(), CoroutineScope, WifiP2pManager.ChannelListene
|
||||
* startService Step 1
|
||||
*/
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
BootReceiver.startIfEnabled()
|
||||
if (status != Status.IDLE) return START_NOT_STICKY
|
||||
val channel = channel ?: return START_NOT_STICKY.also { stopSelf() }
|
||||
status = Status.STARTING
|
||||
// bump self to foreground location service (API 29+) to use location later, also to avoid getting killed
|
||||
if (Build.VERSION.SDK_INT >= 26) showNotification()
|
||||
showNotification()
|
||||
launch {
|
||||
registerReceiver(receiver, intentFilter(WifiP2pManager.WIFI_P2P_STATE_CHANGED_ACTION,
|
||||
WifiP2pManager.WIFI_P2P_CONNECTION_CHANGED_ACTION))
|
||||
val filter = intentFilter(WifiP2pManager.WIFI_P2P_STATE_CHANGED_ACTION,
|
||||
WifiP2pManager.WIFI_P2P_CONNECTION_CHANGED_ACTION)
|
||||
if (Build.VERSION.SDK_INT in 30 until 33) filter.addAction(LocationManager.MODE_CHANGED_ACTION)
|
||||
registerReceiver(receiver, filter)
|
||||
receiverRegistered = true
|
||||
try {
|
||||
p2pManager.requestGroupInfo(channel) {
|
||||
val group = p2pManager.requestGroupInfo(channel)
|
||||
when {
|
||||
it == null -> doStart()
|
||||
it.isGroupOwner -> launch { if (routingManager == null) doStartLocked(it) }
|
||||
group == null -> doStart()
|
||||
group.isGroupOwner -> if (routingManager == null) doStartLocked(group)
|
||||
else -> {
|
||||
Timber.i("Removing old group ($it)")
|
||||
Timber.i("Removing old group ($group)")
|
||||
p2pManager.removeGroup(channel, object : WifiP2pManager.ActionListener {
|
||||
override fun onSuccess() {
|
||||
doStart()
|
||||
launch { doStart() }
|
||||
}
|
||||
override fun onFailure(reason: Int) =
|
||||
startFailure(formatReason(R.string.repeater_remove_old_group_failure, reason))
|
||||
@@ -338,33 +433,33 @@ class RepeaterService : Service(), CoroutineScope, WifiP2pManager.ChannelListene
|
||||
}
|
||||
}
|
||||
}
|
||||
} 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() = launch {
|
||||
private suspend fun doStart() {
|
||||
val listener = object : WifiP2pManager.ActionListener {
|
||||
override fun onFailure(reason: Int) {
|
||||
startFailure(formatReason(R.string.repeater_create_group_failure, reason),
|
||||
showWifiEnable = reason == WifiP2pManager.BUSY)
|
||||
}
|
||||
override fun onSuccess() { } // wait for WIFI_P2P_CONNECTION_CHANGED_ACTION to fire to go to step 3
|
||||
override fun onSuccess() {
|
||||
// wait for WIFI_P2P_CONNECTION_CHANGED_ACTION to fire to go to step 3
|
||||
// in order for this to happen, we need to make sure that the callbacks are firing
|
||||
if (Build.VERSION.SDK_INT in 30 until 33) onLocationModeChanged(app.location?.isLocationEnabled == true)
|
||||
}
|
||||
val channel = channel ?: return@launch listener.onFailure(WifiP2pManager.BUSY)
|
||||
}
|
||||
val channel = channel ?: return listener.onFailure(WifiP2pManager.BUSY)
|
||||
if (!safeMode) {
|
||||
binder.fetchPersistentGroup()
|
||||
setOperatingChannel()
|
||||
}
|
||||
val networkName = networkName
|
||||
if (Build.VERSION.SDK_INT >= 33) setVendorElements()
|
||||
val networkName = networkName?.toString()
|
||||
val passphrase = passphrase
|
||||
try {
|
||||
if (!safeMode || networkName == null || passphrase == null) {
|
||||
@SuppressLint("MissingPermission") // missing permission will simply leading to returning ERROR
|
||||
if (!safeMode || networkName == null || passphrase.isNullOrEmpty()) {
|
||||
persistNextGroup = true
|
||||
p2pManager.createGroup(channel, listener)
|
||||
} else @TargetApi(29) {
|
||||
@@ -373,7 +468,12 @@ class RepeaterService : Service(), CoroutineScope, WifiP2pManager.ChannelListene
|
||||
mNetworkName.set(this, networkName) // bypass networkName check
|
||||
} catch (e: ReflectiveOperationException) {
|
||||
Timber.w(e)
|
||||
try {
|
||||
setNetworkName(networkName)
|
||||
} catch (e: IllegalArgumentException) {
|
||||
Timber.w(e)
|
||||
return startFailure(e.readableMessage)
|
||||
}
|
||||
}
|
||||
setPassphrase(passphrase)
|
||||
when (val oc = operatingChannel) {
|
||||
@@ -389,16 +489,9 @@ class RepeaterService : Service(), CoroutineScope, WifiP2pManager.ChannelListene
|
||||
setGroupOperatingFrequency(SoftApConfigurationCompat.channelToFrequency(operatingBand, oc))
|
||||
}
|
||||
}
|
||||
setDeviceAddress(deviceAddress?.toPlatform())
|
||||
setDeviceAddress(deviceAddress)
|
||||
}.build(), listener)
|
||||
}
|
||||
} catch (e: SecurityException) {
|
||||
Timber.w(e)
|
||||
startFailure(e.readableMessage)
|
||||
} catch (e: IllegalArgumentException) {
|
||||
Timber.w(e)
|
||||
startFailure(e.readableMessage)
|
||||
}
|
||||
}
|
||||
/**
|
||||
* Used during step 2, also called when connection changed
|
||||
@@ -426,7 +519,7 @@ class RepeaterService : Service(), CoroutineScope, WifiP2pManager.ChannelListene
|
||||
}
|
||||
binder.group = group
|
||||
if (persistNextGroup) {
|
||||
networkName = group.networkName
|
||||
networkName = WifiSsidCompat.fromUtf8Text(group.networkName)
|
||||
passphrase = group.passphrase
|
||||
persistNextGroup = false
|
||||
}
|
||||
@@ -434,6 +527,7 @@ class RepeaterService : Service(), CoroutineScope, WifiP2pManager.ChannelListene
|
||||
routingManager = RoutingManager.LocalOnly(this@RepeaterService, group.`interface`!!).apply { start() }
|
||||
status = Status.ACTIVE
|
||||
showNotification(group)
|
||||
BootReceiver.add<RepeaterService>(Starter())
|
||||
}
|
||||
private fun startFailure(msg: CharSequence, group: WifiP2pGroup? = null, showWifiEnable: Boolean = false) {
|
||||
SmartSnackbar.make(msg).apply {
|
||||
@@ -451,7 +545,7 @@ class RepeaterService : Service(), CoroutineScope, WifiP2pManager.ChannelListene
|
||||
if (group == null) emptyMap() else mapOf(Pair(group.`interface`, group.clientList?.size ?: 0)))
|
||||
|
||||
private fun removeGroup() {
|
||||
p2pManager.removeGroup(channel, object : WifiP2pManager.ActionListener {
|
||||
p2pManager.removeGroup(channel ?: return, object : WifiP2pManager.ActionListener {
|
||||
override fun onSuccess() {
|
||||
launch { cleanLocked() }
|
||||
}
|
||||
@@ -459,19 +553,19 @@ class RepeaterService : Service(), CoroutineScope, WifiP2pManager.ChannelListene
|
||||
if (reason != WifiP2pManager.BUSY) {
|
||||
SmartSnackbar.make(formatReason(R.string.repeater_remove_group_failure, reason)).show()
|
||||
} // else assuming it's already gone
|
||||
launch { cleanLocked() }
|
||||
onSuccess()
|
||||
}
|
||||
})
|
||||
}
|
||||
private fun cleanLocked() {
|
||||
BootReceiver.delete<RepeaterService>()
|
||||
if (receiverRegistered) {
|
||||
ensureReceiverUnregistered(receiver)
|
||||
p2pPoller?.cancel()
|
||||
receiverRegistered = false
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= 28) {
|
||||
timeoutMonitor?.close()
|
||||
timeoutMonitor = null
|
||||
}
|
||||
routingManager?.stop()
|
||||
routingManager = null
|
||||
status = Status.IDLE
|
||||
@@ -484,12 +578,11 @@ class RepeaterService : Service(), CoroutineScope, WifiP2pManager.ChannelListene
|
||||
launch { // force clean to prevent leakage
|
||||
cleanLocked()
|
||||
cancel()
|
||||
dispatcher.close()
|
||||
}
|
||||
app.pref.unregisterOnSharedPreferenceChangeListener(this)
|
||||
if (Build.VERSION.SDK_INT < 29) unregisterReceiver(deviceListener)
|
||||
status = Status.DESTROYED
|
||||
if (Build.VERSION.SDK_INT >= 27) channel?.close()
|
||||
channel?.close()
|
||||
super.onDestroy()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
package be.mygod.vpnhotspot
|
||||
|
||||
import android.annotation.TargetApi
|
||||
import android.os.Build
|
||||
import be.mygod.vpnhotspot.App.Companion.app
|
||||
import be.mygod.vpnhotspot.net.Routing
|
||||
@@ -15,15 +14,11 @@ abstract class RoutingManager(private val caller: Any, val downstream: String, p
|
||||
companion object {
|
||||
private const val KEY_MASQUERADE_MODE = "service.masqueradeMode"
|
||||
var masqueradeMode: Routing.MasqueradeMode
|
||||
@TargetApi(28) get() = app.pref.run {
|
||||
get() = app.pref.run {
|
||||
getString(KEY_MASQUERADE_MODE, null)?.let { return@run Routing.MasqueradeMode.valueOf(it) }
|
||||
if (getBoolean("service.masquerade", true)) { // legacy settings
|
||||
Routing.MasqueradeMode.Simple
|
||||
} else Routing.MasqueradeMode.None
|
||||
}.let {
|
||||
// older app version enabled netd for everyone. should check again here
|
||||
if (Build.VERSION.SDK_INT >= 28 || it != Routing.MasqueradeMode.Netd) it
|
||||
else Routing.MasqueradeMode.Simple
|
||||
}
|
||||
set(value) = app.pref.edit().putString(KEY_MASQUERADE_MODE, value.name).apply()
|
||||
|
||||
@@ -66,7 +61,7 @@ abstract class RoutingManager(private val caller: Any, val downstream: String, p
|
||||
private var routing: Routing? = null
|
||||
private var isWifi = forceWifi || TetherType.ofInterface(downstream).isWifi
|
||||
|
||||
fun start() = synchronized(RoutingManager) {
|
||||
fun start(fromMonitor: Boolean = false) = synchronized(RoutingManager) {
|
||||
started = true
|
||||
when (val other = active.putIfAbsent(downstream, this)) {
|
||||
null -> {
|
||||
@@ -78,14 +73,19 @@ abstract class RoutingManager(private val caller: Any, val downstream: String, p
|
||||
isWifi = isWifiNow
|
||||
}
|
||||
}
|
||||
initRoutingLocked()
|
||||
initRoutingLocked(fromMonitor)
|
||||
}
|
||||
this -> true // already started
|
||||
else -> error("Double routing detected for $downstream from $caller != ${other.caller}")
|
||||
else -> {
|
||||
val msg = "Double routing detected for $downstream from $caller != ${other.caller}"
|
||||
Timber.w(RuntimeException(msg))
|
||||
SmartSnackbar.make(msg).show()
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun initRoutingLocked() = try {
|
||||
private fun initRoutingLocked(fromMonitor: Boolean = false) = try {
|
||||
routing = Routing(caller, downstream).apply {
|
||||
try {
|
||||
configure()
|
||||
@@ -97,10 +97,10 @@ abstract class RoutingManager(private val caller: Any, val downstream: String, p
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
when (e) {
|
||||
is Routing.InterfaceNotFoundException -> Timber.d(e)
|
||||
is Routing.InterfaceNotFoundException -> if (!fromMonitor) Timber.d(e)
|
||||
!is CancellationException -> Timber.w(e)
|
||||
}
|
||||
SmartSnackbar.make(e).show()
|
||||
if (e !is Routing.InterfaceNotFoundException || !fromMonitor) SmartSnackbar.make(e).show()
|
||||
routing = null
|
||||
false
|
||||
}
|
||||
|
||||
@@ -1,36 +1,35 @@
|
||||
package be.mygod.vpnhotspot
|
||||
|
||||
import android.annotation.TargetApi
|
||||
import android.app.*
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.content.getSystemService
|
||||
import be.mygod.vpnhotspot.App.Companion.app
|
||||
import java.util.*
|
||||
|
||||
object ServiceNotification {
|
||||
private const val CHANNEL = "tethering"
|
||||
private const val CHANNEL_ID = 1
|
||||
private const val CHANNEL_ACTIVE = "tethering"
|
||||
private const val CHANNEL_INACTIVE = "tethering-inactive"
|
||||
private const val NOTIFICATION_ID = 1
|
||||
|
||||
private val deviceCountsMap = WeakHashMap<Service, Map<String, Int>>()
|
||||
private val inactiveMap = WeakHashMap<Service, List<String>>()
|
||||
private val manager = app.getSystemService<NotificationManager>()!!
|
||||
|
||||
private fun buildNotification(context: Context): Notification {
|
||||
val builder = NotificationCompat.Builder(context, CHANNEL)
|
||||
.setWhen(0)
|
||||
.setCategory(NotificationCompat.CATEGORY_SERVICE)
|
||||
.setColor(ContextCompat.getColor(context, R.color.colorPrimary))
|
||||
.setContentTitle(context.getText(R.string.notification_tethering_title))
|
||||
.setSmallIcon(R.drawable.ic_quick_settings_tile_on)
|
||||
.setContentIntent(PendingIntent.getActivity(context, 0, Intent(context, MainActivity::class.java),
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE))
|
||||
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
|
||||
val deviceCounts = deviceCountsMap.values.flatMap { it.entries }.sortedBy { it.key }
|
||||
val inactive = inactiveMap.values.flatten()
|
||||
val isInactive = inactive.isNotEmpty() && deviceCounts.isEmpty()
|
||||
val builder = Notification.Builder(context, if (isInactive) CHANNEL_INACTIVE else CHANNEL_ACTIVE).apply {
|
||||
setWhen(0)
|
||||
setCategory(Notification.CATEGORY_SERVICE)
|
||||
setColor(context.resources.getColor(R.color.colorPrimary, context.theme))
|
||||
setContentTitle(context.getText(R.string.notification_tethering_title))
|
||||
setSmallIcon(R.drawable.ic_quick_settings_tile_on)
|
||||
setContentIntent(PendingIntent.getActivity(context, 0, Intent(context, MainActivity::class.java),
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE))
|
||||
setVisibility(Notification.VISIBILITY_PUBLIC)
|
||||
}
|
||||
var lines = deviceCounts.map { (dev, size) ->
|
||||
context.resources.getQuantityString(R.plurals.notification_connected_devices, size, size, dev)
|
||||
}
|
||||
@@ -40,13 +39,13 @@ object ServiceNotification {
|
||||
return if (lines.size <= 1) builder.setContentText(lines.singleOrNull()).build() else {
|
||||
val deviceCount = deviceCounts.sumOf { it.value }
|
||||
val interfaceCount = deviceCounts.size + inactive.size
|
||||
NotificationCompat.BigTextStyle(builder
|
||||
.setContentText(context.resources.getQuantityString(R.plurals.notification_connected_devices,
|
||||
deviceCount, deviceCount,
|
||||
Notification.BigTextStyle().apply {
|
||||
setBuilder(builder.setContentText(context.resources.getQuantityString(
|
||||
R.plurals.notification_connected_devices, deviceCount, deviceCount,
|
||||
context.resources.getQuantityString(R.plurals.notification_interfaces,
|
||||
interfaceCount, interfaceCount))))
|
||||
.bigText(lines.joinToString("\n"))
|
||||
.build()!!
|
||||
bigText(lines.joinToString("\n"))
|
||||
}.build()!!
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,26 +53,29 @@ object ServiceNotification {
|
||||
synchronized(this) {
|
||||
deviceCountsMap[service] = deviceCounts
|
||||
if (inactive.isEmpty()) inactiveMap.remove(service) else inactiveMap[service] = inactive
|
||||
service.startForeground(CHANNEL_ID, buildNotification(service))
|
||||
service.startForeground(NOTIFICATION_ID, buildNotification(service))
|
||||
}
|
||||
}
|
||||
fun stopForeground(service: Service) = synchronized(this) {
|
||||
deviceCountsMap.remove(service)
|
||||
if (deviceCountsMap.isEmpty()) service.stopForeground(true) else {
|
||||
service.stopForeground(false)
|
||||
manager.notify(CHANNEL_ID, buildNotification(service))
|
||||
}
|
||||
deviceCountsMap.remove(service) ?: return@synchronized
|
||||
val shutdown = deviceCountsMap.isEmpty()
|
||||
service.stopForeground(if (shutdown) Service.STOP_FOREGROUND_REMOVE else Service.STOP_FOREGROUND_DETACH)
|
||||
if (!shutdown) manager.notify(NOTIFICATION_ID, buildNotification(service))
|
||||
}
|
||||
|
||||
fun updateNotificationChannels() {
|
||||
if (Build.VERSION.SDK_INT >= 26) @TargetApi(26) {
|
||||
val tethering = NotificationChannel(CHANNEL,
|
||||
app.getText(R.string.notification_channel_tethering), NotificationManager.IMPORTANCE_LOW)
|
||||
tethering.lockscreenVisibility = Notification.VISIBILITY_PUBLIC
|
||||
manager.createNotificationChannel(tethering)
|
||||
NotificationChannel(CHANNEL_ACTIVE,
|
||||
app.getText(R.string.notification_channel_tethering), NotificationManager.IMPORTANCE_LOW).apply {
|
||||
lockscreenVisibility = Notification.VISIBILITY_PUBLIC
|
||||
manager.createNotificationChannel(this)
|
||||
}
|
||||
NotificationChannel(CHANNEL_INACTIVE,
|
||||
app.getText(R.string.notification_channel_monitor), NotificationManager.IMPORTANCE_LOW).apply {
|
||||
lockscreenVisibility = Notification.VISIBILITY_PUBLIC
|
||||
manager.createNotificationChannel(this)
|
||||
}
|
||||
// remove old service channels
|
||||
manager.deleteNotificationChannel("hotspot")
|
||||
manager.deleteNotificationChannel("repeater")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
package be.mygod.vpnhotspot
|
||||
|
||||
import android.annotation.TargetApi
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
@@ -8,20 +7,19 @@ import androidx.core.content.FileProvider
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.preference.Preference
|
||||
import androidx.preference.PreferenceFragmentCompat
|
||||
import androidx.preference.SwitchPreference
|
||||
import androidx.preference.TwoStatePreference
|
||||
import be.mygod.vpnhotspot.App.Companion.app
|
||||
import be.mygod.vpnhotspot.net.TetherOffloadManager
|
||||
import be.mygod.vpnhotspot.net.monitor.FallbackUpstreamMonitor
|
||||
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.AlwaysAutoCompleteEditTextPreferenceDialogFragment
|
||||
import be.mygod.vpnhotspot.preference.AutoCompleteNetworkPreferenceDialogFragment
|
||||
import be.mygod.vpnhotspot.preference.SharedPreferenceDataStore
|
||||
import be.mygod.vpnhotspot.preference.SummaryFallbackProvider
|
||||
import be.mygod.vpnhotspot.root.Dump
|
||||
import be.mygod.vpnhotspot.root.RootManager
|
||||
import be.mygod.vpnhotspot.util.Services
|
||||
import be.mygod.vpnhotspot.util.allInterfaceNames
|
||||
import be.mygod.vpnhotspot.util.launchUrl
|
||||
import be.mygod.vpnhotspot.util.showAllowingStateLoss
|
||||
import be.mygod.vpnhotspot.widget.SmartSnackbar
|
||||
@@ -40,7 +38,6 @@ import kotlin.system.exitProcess
|
||||
|
||||
class SettingsPreferenceFragment : PreferenceFragmentCompat() {
|
||||
private fun Preference.remove() = parent!!.removePreference(this)
|
||||
@TargetApi(26)
|
||||
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
|
||||
// handle complicated default value and possible system upgrades
|
||||
WifiDoubleLock.mode = WifiDoubleLock.mode
|
||||
@@ -50,11 +47,10 @@ class SettingsPreferenceFragment : PreferenceFragmentCompat() {
|
||||
addPreferencesFromResource(R.xml.pref_settings)
|
||||
SummaryFallbackProvider(findPreference(UpstreamMonitor.KEY)!!)
|
||||
SummaryFallbackProvider(findPreference(FallbackUpstreamMonitor.KEY)!!)
|
||||
findPreference<SwitchPreference>("system.enableTetherOffload")!!.apply {
|
||||
if (TetherOffloadManager.supported) {
|
||||
findPreference<TwoStatePreference>("system.enableTetherOffload")!!.apply {
|
||||
isChecked = TetherOffloadManager.enabled
|
||||
setOnPreferenceChangeListener { _, newValue ->
|
||||
if (TetherOffloadManager.enabled != newValue) viewLifecycleOwner.lifecycleScope.launchWhenCreated {
|
||||
if (TetherOffloadManager.enabled != newValue) viewLifecycleOwner.lifecycleScope.launch {
|
||||
isEnabled = false
|
||||
try {
|
||||
TetherOffloadManager.setEnabled(newValue as Boolean)
|
||||
@@ -68,16 +64,11 @@ class SettingsPreferenceFragment : PreferenceFragmentCompat() {
|
||||
}
|
||||
false
|
||||
}
|
||||
} else remove()
|
||||
}
|
||||
val boot = findPreference<SwitchPreference>("service.repeater.startOnBoot")!!
|
||||
if (Services.p2p != null) {
|
||||
boot.setOnPreferenceChangeListener { _, value ->
|
||||
BootReceiver.enabled = value as Boolean
|
||||
findPreference<TwoStatePreference>(BootReceiver.KEY)!!.setOnPreferenceChangeListener { _, value ->
|
||||
BootReceiver.onUserSettingUpdated(value as Boolean)
|
||||
true
|
||||
}
|
||||
boot.isChecked = BootReceiver.enabled
|
||||
} else boot.remove()
|
||||
if (Services.p2p == null || !RepeaterService.safeModeConfigurable) {
|
||||
val safeMode = findPreference<Preference>(RepeaterService.KEY_SAFE_MODE)!!
|
||||
safeMode.remove()
|
||||
@@ -130,7 +121,7 @@ class SettingsPreferenceFragment : PreferenceFragmentCompat() {
|
||||
.setFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
.putExtra(Intent.EXTRA_STREAM,
|
||||
FileProvider.getUriForFile(context, "be.mygod.vpnhotspot.log", logFile)),
|
||||
context.getString(R.string.abc_shareactionprovider_share_with)))
|
||||
context.getString(androidx.appcompat.R.string.abc_shareactionprovider_share_with)))
|
||||
}
|
||||
true
|
||||
}
|
||||
@@ -148,16 +139,12 @@ class SettingsPreferenceFragment : PreferenceFragmentCompat() {
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDisplayPreferenceDialog(preference: Preference) {
|
||||
when (preference.key) {
|
||||
override fun onDisplayPreferenceDialog(preference: Preference) = when (preference.key) {
|
||||
UpstreamMonitor.KEY, FallbackUpstreamMonitor.KEY ->
|
||||
AlwaysAutoCompleteEditTextPreferenceDialogFragment().apply {
|
||||
setArguments(preference.key, Services.connectivity.allNetworks.mapNotNull {
|
||||
Services.connectivity.getLinkProperties(it)?.allInterfaceNames
|
||||
}.flatten().toTypedArray())
|
||||
AutoCompleteNetworkPreferenceDialogFragment().apply {
|
||||
setArguments(preference.key)
|
||||
setTargetFragment(this@SettingsPreferenceFragment, 0)
|
||||
}.showAllowingStateLoss(parentFragmentManager, preference.key)
|
||||
else -> super.onDisplayPreferenceDialog(preference)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
package be.mygod.vpnhotspot
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import androidx.annotation.RequiresApi
|
||||
import be.mygod.vpnhotspot.App.Companion.app
|
||||
import be.mygod.vpnhotspot.net.Routing
|
||||
@@ -10,6 +10,7 @@ import be.mygod.vpnhotspot.net.monitor.IpNeighbourMonitor
|
||||
import be.mygod.vpnhotspot.util.Event0
|
||||
import be.mygod.vpnhotspot.widget.SmartSnackbar
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import timber.log.Timber
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
@@ -17,6 +18,7 @@ class TetheringService : IpNeighbourMonitoringService(), TetheringManager.Tether
|
||||
companion object {
|
||||
const val EXTRA_ADD_INTERFACES = "interface.add"
|
||||
const val EXTRA_ADD_INTERFACE_MONITOR = "interface.add.monitor"
|
||||
const val EXTRA_ADD_INTERFACES_MONITOR = "interface.adds.monitor"
|
||||
const val EXTRA_REMOVE_INTERFACE = "interface.remove"
|
||||
}
|
||||
|
||||
@@ -39,10 +41,19 @@ class TetheringService : IpNeighbourMonitoringService(), TetheringManager.Tether
|
||||
}
|
||||
}
|
||||
|
||||
@Parcelize
|
||||
data class Starter(val monitored: ArrayList<String>) : BootReceiver.Startable {
|
||||
override fun start(context: Context) {
|
||||
context.startForegroundService(Intent(context, TetheringService::class.java).apply {
|
||||
putStringArrayListExtra(EXTRA_ADD_INTERFACES_MONITOR, monitored)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes and critical reads to downstreams should be protected with this context.
|
||||
*/
|
||||
private val dispatcher = newSingleThreadContext("TetheringService")
|
||||
private val dispatcher = Dispatchers.IO.limitedParallelism(1)
|
||||
override val coroutineContext = dispatcher + Job()
|
||||
private val binder = Binder()
|
||||
private val downstreams = ConcurrentHashMap<String, Downstream>()
|
||||
@@ -55,7 +66,7 @@ class TetheringService : IpNeighbourMonitoringService(), TetheringManager.Tether
|
||||
val toRemove = downstreams.toMutableMap() // make a copy
|
||||
for (iface in interfaces) {
|
||||
val downstream = toRemove.remove(iface) ?: continue
|
||||
if (downstream.monitor) downstream.start()
|
||||
if (downstream.monitor && !downstream.start()) downstream.stop()
|
||||
}
|
||||
for ((iface, downstream) in toRemove) {
|
||||
if (!downstream.monitor) check(downstreams.remove(iface, downstream))
|
||||
@@ -81,6 +92,10 @@ class TetheringService : IpNeighbourMonitoringService(), TetheringManager.Tether
|
||||
ServiceNotification.stopForeground(this)
|
||||
stopSelf()
|
||||
} else {
|
||||
binder.monitoredIfaces.also {
|
||||
if (it.isEmpty()) BootReceiver.delete<TetheringService>()
|
||||
else BootReceiver.add<TetheringService>(Starter(ArrayList(it)))
|
||||
}
|
||||
if (!callbackRegistered) {
|
||||
callbackRegistered = true
|
||||
TetheringManager.registerTetheringEventCallbackCompat(this, this)
|
||||
@@ -94,8 +109,9 @@ class TetheringService : IpNeighbourMonitoringService(), TetheringManager.Tether
|
||||
override fun onBind(intent: Intent?) = binder
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
BootReceiver.startIfEnabled()
|
||||
// call this first just in case we are shutting down immediately
|
||||
if (Build.VERSION.SDK_INT >= 26) updateNotification()
|
||||
updateNotification()
|
||||
launch {
|
||||
if (intent != null) {
|
||||
for (iface in intent.getStringArrayExtra(EXTRA_ADD_INTERFACES) ?: emptyArray()) {
|
||||
@@ -103,10 +119,12 @@ class TetheringService : IpNeighbourMonitoringService(), TetheringManager.Tether
|
||||
if (start()) check(downstreams.put(iface, this) == null) else stop()
|
||||
}
|
||||
}
|
||||
intent.getStringExtra(EXTRA_ADD_INTERFACE_MONITOR)?.also { iface ->
|
||||
val monitorList = intent.getStringArrayListExtra(EXTRA_ADD_INTERFACES_MONITOR) ?:
|
||||
intent.getStringExtra(EXTRA_ADD_INTERFACE_MONITOR)?.let { listOf(it) }
|
||||
if (!monitorList.isNullOrEmpty()) for (iface in monitorList) {
|
||||
val downstream = downstreams[iface]
|
||||
if (downstream == null) Downstream(this@TetheringService, iface, true).apply {
|
||||
start()
|
||||
if (!start(true)) stop()
|
||||
check(downstreams.put(iface, this) == null)
|
||||
downstreams[iface] = this
|
||||
} else downstream.monitor = true
|
||||
@@ -120,10 +138,10 @@ class TetheringService : IpNeighbourMonitoringService(), TetheringManager.Tether
|
||||
|
||||
override fun onDestroy() {
|
||||
launch {
|
||||
BootReceiver.delete<TetheringService>()
|
||||
unregisterReceiver()
|
||||
downstreams.values.forEach { it.stop() } // force clean to prevent leakage
|
||||
cancel()
|
||||
dispatcher.close()
|
||||
}
|
||||
super.onDestroy()
|
||||
}
|
||||
@@ -135,4 +153,8 @@ class TetheringService : IpNeighbourMonitoringService(), TetheringManager.Tether
|
||||
callbackRegistered = false
|
||||
}
|
||||
}
|
||||
|
||||
override fun updateNotification() {
|
||||
launch { super.updateNotification() }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package be.mygod.vpnhotspot.client
|
||||
|
||||
import android.net.MacAddress
|
||||
import android.text.SpannableStringBuilder
|
||||
import android.text.Spanned
|
||||
import android.text.style.StrikethroughSpan
|
||||
@@ -9,7 +10,6 @@ import be.mygod.vpnhotspot.App.Companion.app
|
||||
import be.mygod.vpnhotspot.R
|
||||
import be.mygod.vpnhotspot.net.InetAddressComparator
|
||||
import be.mygod.vpnhotspot.net.IpNeighbour
|
||||
import be.mygod.vpnhotspot.net.MacAddressCompat
|
||||
import be.mygod.vpnhotspot.net.TetherType
|
||||
import be.mygod.vpnhotspot.room.AppDatabase
|
||||
import be.mygod.vpnhotspot.room.ClientRecord
|
||||
@@ -18,7 +18,7 @@ import be.mygod.vpnhotspot.util.makeMacSpan
|
||||
import java.net.InetAddress
|
||||
import java.util.*
|
||||
|
||||
open class Client(val mac: MacAddressCompat, val iface: String) {
|
||||
open class Client(val mac: MacAddress, val iface: String) {
|
||||
companion object DiffCallback : DiffUtil.ItemCallback<Client>() {
|
||||
override fun areItemsTheSame(oldItem: Client, newItem: Client) =
|
||||
oldItem.iface == newItem.iface && oldItem.mac == newItem.mac
|
||||
@@ -42,10 +42,10 @@ open class Client(val mac: MacAddressCompat, val iface: String) {
|
||||
* 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.isEmpty()) {
|
||||
SpannableStringBuilder(record.nickname.ifEmpty {
|
||||
if (record.macLookupPending) MacLookup.perform(mac)
|
||||
macIface
|
||||
} else emojize(record.nickname)).apply {
|
||||
}).apply {
|
||||
if (record.blocked) setSpan(StrikethroughSpan(), 0, length, Spanned.SPAN_INCLUSIVE_INCLUSIVE)
|
||||
}
|
||||
}
|
||||
@@ -65,7 +65,7 @@ open class Client(val mac: MacAddressCompat, val iface: String) {
|
||||
}.trimEnd()
|
||||
}
|
||||
|
||||
fun obtainRecord() = record.value ?: ClientRecord(mac.addr)
|
||||
fun obtainRecord() = record.value ?: ClientRecord(mac)
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
|
||||
@@ -3,11 +3,12 @@ package be.mygod.vpnhotspot.client
|
||||
import android.content.ComponentName
|
||||
import android.content.IntentFilter
|
||||
import android.content.ServiceConnection
|
||||
import android.net.MacAddress
|
||||
import android.net.wifi.p2p.WifiP2pDevice
|
||||
import android.os.Build
|
||||
import android.os.IBinder
|
||||
import android.os.Parcelable
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.core.os.BuildCompat
|
||||
import androidx.lifecycle.DefaultLifecycleObserver
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
@@ -15,8 +16,6 @@ import androidx.lifecycle.ViewModel
|
||||
import be.mygod.vpnhotspot.App.Companion.app
|
||||
import be.mygod.vpnhotspot.RepeaterService
|
||||
import be.mygod.vpnhotspot.net.IpNeighbour
|
||||
import be.mygod.vpnhotspot.net.MacAddressCompat
|
||||
import be.mygod.vpnhotspot.net.MacAddressCompat.Companion.toCompat
|
||||
import be.mygod.vpnhotspot.net.TetherType
|
||||
import be.mygod.vpnhotspot.net.TetheringManager
|
||||
import be.mygod.vpnhotspot.net.TetheringManager.localOnlyTetheredIfaces
|
||||
@@ -38,7 +37,7 @@ class ClientViewModel : ViewModel(), ServiceConnection, IpNeighbourMonitor.Callb
|
||||
|
||||
private var repeater: RepeaterService.Binder? = null
|
||||
private var p2p: Collection<WifiP2pDevice> = emptyList()
|
||||
private var wifiAp = emptyList<Pair<String, MacAddressCompat>>()
|
||||
private var wifiAp = emptyList<Pair<String, MacAddress>>()
|
||||
private var neighbours: Collection<IpNeighbour> = emptyList()
|
||||
val clients = MutableLiveData<List<Client>>()
|
||||
val fullMode = object : DefaultLifecycleObserver {
|
||||
@@ -51,10 +50,10 @@ class ClientViewModel : ViewModel(), ServiceConnection, IpNeighbourMonitor.Callb
|
||||
}
|
||||
|
||||
private fun populateClients() {
|
||||
val clients = HashMap<Pair<String, MacAddressCompat>, Client>()
|
||||
val clients = HashMap<Pair<String, MacAddress>, Client>()
|
||||
repeater?.group?.`interface`?.let { p2pInterface ->
|
||||
for (client in p2p) {
|
||||
val addr = MacAddressCompat.fromString(client.deviceAddress!!)
|
||||
val addr = MacAddress.fromString(client.deviceAddress!!)
|
||||
clients[p2pInterface to addr] = object : Client(addr, p2pInterface) {
|
||||
override val icon: Int get() = TetherType.WIFI_P2P.icon
|
||||
}
|
||||
@@ -87,10 +86,10 @@ class ClientViewModel : ViewModel(), ServiceConnection, IpNeighbourMonitor.Callb
|
||||
override fun onStart(owner: LifecycleOwner) {
|
||||
app.registerReceiver(receiver, IntentFilter(TetheringManager.ACTION_TETHER_STATE_CHANGED))
|
||||
IpNeighbourMonitor.registerCallback(this, false)
|
||||
if (BuildCompat.isAtLeastS()) WifiApCommands.registerSoftApCallback(this)
|
||||
if (Build.VERSION.SDK_INT >= 31) WifiApCommands.registerSoftApCallback(this)
|
||||
}
|
||||
override fun onStop(owner: LifecycleOwner) {
|
||||
if (BuildCompat.isAtLeastS()) WifiApCommands.unregisterSoftApCallback(this)
|
||||
if (Build.VERSION.SDK_INT >= 31) WifiApCommands.unregisterSoftApCallback(this)
|
||||
IpNeighbourMonitor.unregisterCallback(this)
|
||||
app.unregisterReceiver(receiver)
|
||||
}
|
||||
@@ -118,7 +117,7 @@ class ClientViewModel : ViewModel(), ServiceConnection, IpNeighbourMonitor.Callb
|
||||
override fun onConnectedClientsChanged(clients: List<Parcelable>) {
|
||||
wifiAp = clients.mapNotNull {
|
||||
val client = WifiClient(it)
|
||||
client.apInstanceIdentifier?.run { this to client.macAddress.toCompat() }
|
||||
client.apInstanceIdentifier?.run { this to client.macAddress }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package be.mygod.vpnhotspot.client
|
||||
|
||||
import android.content.DialogInterface
|
||||
import android.net.MacAddress
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.os.Parcelable
|
||||
@@ -20,6 +21,7 @@ import androidx.fragment.app.Fragment
|
||||
import androidx.fragment.app.activityViewModels
|
||||
import androidx.lifecycle.findViewTreeLifecycleOwner
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.lifecycle.withStarted
|
||||
import androidx.recyclerview.widget.DefaultItemAnimator
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.ListAdapter
|
||||
@@ -30,7 +32,6 @@ import be.mygod.vpnhotspot.Empty
|
||||
import be.mygod.vpnhotspot.R
|
||||
import be.mygod.vpnhotspot.databinding.FragmentClientsBinding
|
||||
import be.mygod.vpnhotspot.databinding.ListitemClientBinding
|
||||
import be.mygod.vpnhotspot.net.MacAddressCompat
|
||||
import be.mygod.vpnhotspot.net.TetherType
|
||||
import be.mygod.vpnhotspot.net.monitor.IpNeighbourMonitor
|
||||
import be.mygod.vpnhotspot.net.monitor.TrafficRecorder
|
||||
@@ -41,21 +42,24 @@ import be.mygod.vpnhotspot.util.format
|
||||
import be.mygod.vpnhotspot.util.showAllowingStateLoss
|
||||
import be.mygod.vpnhotspot.util.toPluralInt
|
||||
import be.mygod.vpnhotspot.widget.SmartSnackbar
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import java.text.NumberFormat
|
||||
|
||||
class ClientsFragment : Fragment() {
|
||||
// FIXME: value class does not work with Parcelize
|
||||
@Parcelize
|
||||
data class NicknameArg(val mac: Long, val nickname: CharSequence) : Parcelable
|
||||
data class NicknameArg(val mac: MacAddress, val nickname: CharSequence) : Parcelable
|
||||
class NicknameDialogFragment : AlertDialogFragment<NicknameArg, Empty>() {
|
||||
override fun AlertDialog.Builder.prepare(listener: DialogInterface.OnClickListener) {
|
||||
setView(R.layout.dialog_nickname)
|
||||
setTitle(getString(R.string.clients_nickname_title, MacAddressCompat(arg.mac).toString()))
|
||||
setTitle(getString(R.string.clients_nickname_title, arg.mac))
|
||||
setPositiveButton(android.R.string.ok, listener)
|
||||
setNegativeButton(android.R.string.cancel, null)
|
||||
setNeutralButton(emojize(getText(R.string.clients_nickname_set_to_vendor)), listener)
|
||||
setNeutralButton(getText(R.string.clients_nickname_set_to_vendor), listener)
|
||||
}
|
||||
|
||||
override fun onCreateDialog(savedInstanceState: Bundle?) = super.onCreateDialog(savedInstanceState).apply {
|
||||
@@ -64,7 +68,7 @@ class ClientsFragment : Fragment() {
|
||||
}
|
||||
|
||||
override fun onClick(dialog: DialogInterface?, which: Int) {
|
||||
val mac = MacAddressCompat(arg.mac)
|
||||
val mac = arg.mac
|
||||
when (which) {
|
||||
DialogInterface.BUTTON_POSITIVE -> {
|
||||
val newNickname = this.dialog!!.findViewById<EditText>(android.R.id.edit).text
|
||||
@@ -84,7 +88,7 @@ class ClientsFragment : Fragment() {
|
||||
override fun AlertDialog.Builder.prepare(listener: DialogInterface.OnClickListener) {
|
||||
val context = context
|
||||
val resources = resources
|
||||
val locale = resources.configuration.locale
|
||||
val locale = resources.configuration.locales[0]
|
||||
setTitle(getText(R.string.clients_stats_title).format(locale, arg.title))
|
||||
val format = NumberFormat.getIntegerInstance(locale)
|
||||
setMessage("%s\n%s\n%s".format(
|
||||
@@ -135,7 +139,7 @@ class ClientsFragment : Fragment() {
|
||||
R.id.nickname -> {
|
||||
val client = binding.client ?: return false
|
||||
NicknameDialogFragment().apply {
|
||||
arg(NicknameArg(client.mac.addr, client.nickname))
|
||||
arg(NicknameArg(client.mac, client.nickname))
|
||||
}.showAllowingStateLoss(parentFragmentManager)
|
||||
true
|
||||
}
|
||||
@@ -155,14 +159,16 @@ class ClientsFragment : Fragment() {
|
||||
true
|
||||
}
|
||||
R.id.stats -> {
|
||||
binding.client?.let { client ->
|
||||
viewLifecycleOwner.lifecycleScope.launchWhenCreated {
|
||||
withContext(Dispatchers.Unconfined) {
|
||||
StatsDialogFragment().apply {
|
||||
arg(StatsArg(client.title.value ?: return@withContext,
|
||||
AppDatabase.instance.trafficRecordDao.queryStats(client.mac.addr)))
|
||||
}.showAllowingStateLoss(parentFragmentManager)
|
||||
val client = binding.client
|
||||
val title = client?.title?.value ?: return false
|
||||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
val stats = withContext(Dispatchers.Unconfined) {
|
||||
AppDatabase.instance.trafficRecordDao.queryStats(client.mac)
|
||||
}
|
||||
withStarted {
|
||||
StatsDialogFragment().apply {
|
||||
arg(StatsArg(title, stats))
|
||||
}.showAllowingStateLoss(parentFragmentManager)
|
||||
}
|
||||
}
|
||||
true
|
||||
@@ -201,9 +207,7 @@ class ClientsFragment : Fragment() {
|
||||
check(newRecord.receivedPackets == oldRecord.receivedPackets)
|
||||
check(newRecord.receivedBytes == oldRecord.receivedBytes)
|
||||
} else {
|
||||
val rate = rates.computeIfAbsent(newRecord.downstream to MacAddressCompat(newRecord.mac)) {
|
||||
TrafficRate()
|
||||
}
|
||||
val rate = rates.computeIfAbsent(newRecord.downstream to newRecord.mac) { TrafficRate() }
|
||||
if (rate.send < 0 || rate.receive < 0) {
|
||||
rate.send = 0
|
||||
rate.receive = 0
|
||||
@@ -218,7 +222,7 @@ class ClientsFragment : Fragment() {
|
||||
|
||||
private lateinit var binding: FragmentClientsBinding
|
||||
private val adapter = ClientAdapter()
|
||||
private var rates = mutableMapOf<Pair<String, MacAddressCompat>, TrafficRate>()
|
||||
private var rates = mutableMapOf<Pair<String, MacAddress>, TrafficRate>()
|
||||
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||
binding = FragmentClientsBinding.inflate(inflater, container, false)
|
||||
@@ -237,14 +241,19 @@ class ClientsFragment : Fragment() {
|
||||
override fun onStart() {
|
||||
// icon might be changed due to TetherType changes
|
||||
if (Build.VERSION.SDK_INT >= 30) TetherType.listener[this] = {
|
||||
lifecycleScope.launchWhenStarted { adapter.notifyItemRangeChanged(0, adapter.size.await()) }
|
||||
lifecycleScope.launch {
|
||||
val size = adapter.size.await()
|
||||
withStarted { adapter.notifyItemRangeChanged(0, size) }
|
||||
}
|
||||
}
|
||||
super.onStart()
|
||||
// we just put these two thing together as this is the only place we need to use this event for now
|
||||
TrafficRecorder.foregroundListeners[this] = { newRecords, oldRecords ->
|
||||
lifecycleScope.launchWhenStarted { adapter.updateTraffic(newRecords, oldRecords) }
|
||||
lifecycleScope.launch {
|
||||
withStarted { adapter.updateTraffic(newRecords, oldRecords) }
|
||||
}
|
||||
lifecycleScope.launchWhenStarted {
|
||||
}
|
||||
lifecycleScope.launch {
|
||||
withContext(Dispatchers.Default) {
|
||||
TrafficRecorder.rescheduleUpdate() // next schedule time might be 1 min, force reschedule to <= 1s
|
||||
}
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
package be.mygod.vpnhotspot.client
|
||||
|
||||
import androidx.emoji.text.EmojiCompat
|
||||
|
||||
fun emojize(text: CharSequence?): CharSequence? = if (text == null) null else try {
|
||||
EmojiCompat.get().process(text)
|
||||
} catch (_: IllegalStateException) {
|
||||
text
|
||||
}
|
||||
@@ -1,78 +1,134 @@
|
||||
package be.mygod.vpnhotspot.client
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.net.MacAddress
|
||||
import androidx.annotation.MainThread
|
||||
import be.mygod.vpnhotspot.App.Companion.app
|
||||
import be.mygod.vpnhotspot.R
|
||||
import be.mygod.vpnhotspot.net.MacAddressCompat
|
||||
import be.mygod.vpnhotspot.room.AppDatabase
|
||||
import be.mygod.vpnhotspot.util.connectCancellable
|
||||
import be.mygod.vpnhotspot.widget.SmartSnackbar
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import org.json.JSONException
|
||||
import org.json.JSONObject
|
||||
import timber.log.Timber
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
import java.io.File
|
||||
import java.io.FileNotFoundException
|
||||
import java.io.IOException
|
||||
import java.net.HttpCookie
|
||||
import java.util.Scanner
|
||||
import java.util.regex.Pattern
|
||||
|
||||
/**
|
||||
* This class generates a default nickname for new clients.
|
||||
*/
|
||||
object MacLookup {
|
||||
class UnexpectedError(val mac: MacAddressCompat, val error: String) : JSONException("") {
|
||||
class UnexpectedError(val mac: MacAddress, val error: String) : JSONException("") {
|
||||
private fun formatMessage(context: Context) =
|
||||
context.getString(R.string.clients_mac_lookup_unexpected_error, mac.toOui(), error)
|
||||
context.getString(R.string.clients_mac_lookup_unexpected_error,
|
||||
mac.toByteArray().joinToString("") { "%02x".format(it) }.substring(0, 9), error)
|
||||
override val message get() = formatMessage(app.english)
|
||||
override fun getLocalizedMessage() = formatMessage(app)
|
||||
}
|
||||
|
||||
private val macLookupBusy = mutableMapOf<MacAddressCompat, Pair<HttpURLConnection, Job>>()
|
||||
private object SessionManager {
|
||||
private const val CACHE_FILENAME = "maclookup_sessioncache"
|
||||
private const val COOKIE_SESSION = "mac_address_vendor_lookup_session"
|
||||
private val csrfPattern = Pattern.compile("<meta\\s+name=\"csrf-token\"\\s+content=\"([^\"]*)\"",
|
||||
Pattern.CASE_INSENSITIVE)
|
||||
private var sessionCache: List<String>?
|
||||
get() = try {
|
||||
File(app.deviceStorage.cacheDir, CACHE_FILENAME).readText().split('\n', limit = 2)
|
||||
} catch (_: FileNotFoundException) {
|
||||
null
|
||||
}
|
||||
set(value) = File(app.deviceStorage.cacheDir, CACHE_FILENAME).run {
|
||||
if (value != null) writeText(value.joinToString("\n")) else if (!delete()) writeText("")
|
||||
}
|
||||
private val mutex = Mutex()
|
||||
|
||||
private suspend fun refreshSessionCache() = connectCancellable("https://macaddress.io/api") { conn ->
|
||||
val cookies = conn.headerFields["set-cookie"] ?: throw IOException("Missing cookies")
|
||||
var mavls: HttpCookie? = null
|
||||
for (header in cookies) for (cookie in HttpCookie.parse(header)) {
|
||||
if (cookie.name == COOKIE_SESSION) mavls = cookie
|
||||
}
|
||||
if (mavls == null) throw IOException("Missing set-cookie $COOKIE_SESSION")
|
||||
val token = conn.inputStream.use { Scanner(it).findWithinHorizon(csrfPattern, 0) }
|
||||
?: throw IOException("Missing csrf-token")
|
||||
listOf(mavls.toString(), csrfPattern.matcher(token).run {
|
||||
check(matches())
|
||||
group(1)!!
|
||||
}).also { sessionCache = it }
|
||||
}
|
||||
|
||||
suspend fun obtain(forceNew: Boolean): Pair<HttpCookie, String> = mutex.withLock {
|
||||
val sessionCache = (if (forceNew) null else sessionCache) ?: refreshSessionCache()
|
||||
HttpCookie.parse(sessionCache[0]).single() to sessionCache[1]
|
||||
}
|
||||
}
|
||||
|
||||
private val macLookupBusy = mutableMapOf<MacAddress, Job>()
|
||||
// http://en.wikipedia.org/wiki/ISO_3166-1
|
||||
private val countryCodeRegex = "(?:^|[^A-Z])([A-Z]{2})[\\s\\d]*$".toRegex()
|
||||
|
||||
@MainThread
|
||||
fun abort(mac: MacAddressCompat) = macLookupBusy.remove(mac)?.let { (conn, job) ->
|
||||
job.cancel()
|
||||
if (Build.VERSION.SDK_INT < 26) GlobalScope.launch(Dispatchers.IO) { conn.disconnect() } else conn.disconnect()
|
||||
}
|
||||
fun abort(mac: MacAddress) = macLookupBusy.remove(mac)?.cancel()
|
||||
|
||||
@MainThread
|
||||
fun perform(mac: MacAddressCompat, explicit: Boolean = false) {
|
||||
fun perform(mac: MacAddress, explicit: Boolean = false) {
|
||||
abort(mac)
|
||||
val conn = URL("https://macvendors.co/api/$mac").openConnection() as HttpURLConnection
|
||||
macLookupBusy[mac] = conn to GlobalScope.launch(Dispatchers.IO) {
|
||||
macLookupBusy[mac] = GlobalScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
val response = conn.inputStream.bufferedReader().readText()
|
||||
val obj = JSONObject(response).getJSONObject("result")
|
||||
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) {
|
||||
String(match.groupValues[1].flatMap { listOf('\uD83C', it + 0xDDA5) }.toCharArray()) + ' ' + company
|
||||
var response: String? = null
|
||||
for (tries in 0 until 5) {
|
||||
val (cookie, csrf) = SessionManager.obtain(tries > 0)
|
||||
response = connectCancellable("https://macaddress.io/mac-address-lookup") { conn ->
|
||||
conn.requestMethod = "POST"
|
||||
conn.setRequestProperty("content-type", "application/json")
|
||||
conn.setRequestProperty("cookie", "${cookie.name}=${cookie.value}")
|
||||
conn.setRequestProperty("x-csrf-token", csrf)
|
||||
conn.outputStream.writer().use { it.write("{\"macAddress\":\"$mac\",\"not-web-search\":true}") }
|
||||
when (val responseCode = conn.responseCode) {
|
||||
200 -> conn.inputStream.bufferedReader().readText()
|
||||
419 -> null
|
||||
else -> throw IOException("Unhandled response code $responseCode")
|
||||
}
|
||||
}
|
||||
if (response != null) break
|
||||
}
|
||||
if (response == null) throw IOException("Session creation failure")
|
||||
val obj = JSONObject(response)
|
||||
val result = if (obj.getJSONObject("blockDetails").getBoolean("blockFound")) {
|
||||
val vendor = obj.getJSONObject("vendorDetails")
|
||||
val company = vendor.getString("companyName")
|
||||
val match = extractCountry(mac, response, vendor)
|
||||
if (match != null) {
|
||||
String(match.groupValues[1].flatMap { listOf('\uD83C', it + 0xDDA5) }.toCharArray()) + ' ' +
|
||||
company
|
||||
} else company
|
||||
} else null
|
||||
AppDatabase.instance.clientRecordDao.upsert(mac) {
|
||||
nickname = result
|
||||
if (result != null) nickname = result
|
||||
macLookupPending = false
|
||||
}
|
||||
} catch (e: JSONException) {
|
||||
if ((e as? UnexpectedError)?.error == "no result") {
|
||||
// no vendor found, we should not retry in the future
|
||||
AppDatabase.instance.clientRecordDao.upsert(mac) { macLookupPending = false }
|
||||
} else Timber.w(e)
|
||||
if (explicit) SmartSnackbar.make(e).show()
|
||||
} catch (_: CancellationException) {
|
||||
} catch (e: Throwable) {
|
||||
Timber.d(e)
|
||||
Timber.w(e)
|
||||
if (explicit) SmartSnackbar.make(e).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun extractCountry(mac: MacAddressCompat, response: String, obj: JSONObject): MatchResult? {
|
||||
countryCodeRegex.matchEntire(obj.optString("country"))?.also { return it }
|
||||
val address = obj.optString("address")
|
||||
private fun extractCountry(mac: MacAddress, response: String, obj: JSONObject): MatchResult? {
|
||||
countryCodeRegex.matchEntire(obj.optString("countryCode"))?.also { return it }
|
||||
val address = obj.optString("companyAddress")
|
||||
if (address.isBlank()) return null
|
||||
countryCodeRegex.find(address)?.also { return it }
|
||||
Timber.w(UnexpectedError(mac, response))
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
package be.mygod.vpnhotspot.manage
|
||||
|
||||
import android.annotation.TargetApi
|
||||
import android.annotation.SuppressLint
|
||||
import android.bluetooth.BluetoothAdapter
|
||||
import android.bluetooth.BluetoothProfile
|
||||
import android.content.BroadcastReceiver
|
||||
@@ -8,8 +8,6 @@ import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.os.Build
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.core.os.BuildCompat
|
||||
import be.mygod.vpnhotspot.App.Companion.app
|
||||
import be.mygod.vpnhotspot.net.TetheringManager
|
||||
import be.mygod.vpnhotspot.util.broadcastReceiver
|
||||
@@ -18,7 +16,7 @@ import be.mygod.vpnhotspot.widget.SmartSnackbar
|
||||
import timber.log.Timber
|
||||
import java.lang.reflect.InvocationTargetException
|
||||
|
||||
class BluetoothTethering(context: Context, val stateListener: () -> Unit) :
|
||||
class BluetoothTethering(context: Context, private val adapter: BluetoothAdapter, val stateListener: () -> Unit) :
|
||||
BluetoothProfile.ServiceListener, AutoCloseable {
|
||||
companion object : BroadcastReceiver() {
|
||||
/**
|
||||
@@ -26,17 +24,9 @@ class BluetoothTethering(context: Context, val stateListener: () -> Unit) :
|
||||
*/
|
||||
private const val PAN = 5
|
||||
private val clazz by lazy { Class.forName("android.bluetooth.BluetoothPan") }
|
||||
private val constructor by lazy {
|
||||
clazz.getDeclaredConstructor(Context::class.java, BluetoothProfile.ServiceListener::class.java).apply {
|
||||
isAccessible = true
|
||||
}
|
||||
}
|
||||
private val isTetheringOn by lazy { clazz.getDeclaredMethod("isTetheringOn") }
|
||||
|
||||
fun pan(context: Context, serviceListener: BluetoothProfile.ServiceListener) =
|
||||
constructor.newInstance(context, serviceListener) as BluetoothProfile
|
||||
val BluetoothProfile.isTetheringOn get() = isTetheringOn(this) as Boolean
|
||||
fun BluetoothProfile.closePan() = BluetoothAdapter.getDefaultAdapter()!!.closeProfileProxy(PAN, this)
|
||||
private val BluetoothProfile.isTetheringOn get() = isTetheringOn(this) as Boolean
|
||||
|
||||
private fun registerBluetoothStateListener(receiver: BroadcastReceiver) =
|
||||
app.registerReceiver(receiver, IntentFilter(BluetoothAdapter.ACTION_STATE_CHANGED))
|
||||
@@ -46,7 +36,6 @@ class BluetoothTethering(context: Context, val stateListener: () -> Unit) :
|
||||
/**
|
||||
* https://android.googlesource.com/platform/packages/apps/Settings/+/b1af85d/src/com/android/settings/TetherSettings.java#215
|
||||
*/
|
||||
@TargetApi(24)
|
||||
override fun onReceive(context: Context?, intent: Intent?) {
|
||||
when (intent?.getIntExtra(BluetoothAdapter.EXTRA_STATE, BluetoothAdapter.ERROR)) {
|
||||
BluetoothAdapter.STATE_ON -> {
|
||||
@@ -58,28 +47,12 @@ class BluetoothTethering(context: Context, val stateListener: () -> Unit) :
|
||||
pendingCallback = null
|
||||
app.unregisterReceiver(this)
|
||||
}
|
||||
|
||||
/**
|
||||
* https://android.googlesource.com/platform/packages/apps/Settings/+/b1af85d/src/com/android/settings/TetherSettings.java#384
|
||||
*/
|
||||
@RequiresApi(24)
|
||||
fun start(callback: TetheringManager.StartTetheringCallback) {
|
||||
if (pendingCallback != null) return
|
||||
val adapter = BluetoothAdapter.getDefaultAdapter()
|
||||
try {
|
||||
if (adapter?.state == BluetoothAdapter.STATE_OFF) {
|
||||
registerBluetoothStateListener(this)
|
||||
pendingCallback = callback
|
||||
adapter.enable()
|
||||
} else TetheringManager.startTethering(TetheringManager.TETHERING_BLUETOOTH, true, callback)
|
||||
} catch (e: SecurityException) {
|
||||
SmartSnackbar.make(e.readableMessage).shortToast().show()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var proxyCreated = false
|
||||
private var connected = false
|
||||
private var pan: BluetoothProfile? = null
|
||||
private var stoppedByUser = false
|
||||
var activeFailureCause: Throwable? = null
|
||||
/**
|
||||
* Based on: https://android.googlesource.com/platform/packages/apps/Settings/+/78d5efd/src/com/android/settings/TetherSettings.java
|
||||
@@ -88,7 +61,7 @@ class BluetoothTethering(context: Context, val stateListener: () -> Unit) :
|
||||
val pan = pan ?: return null
|
||||
if (!connected) return null
|
||||
activeFailureCause = null
|
||||
return BluetoothAdapter.getDefaultAdapter()?.state == BluetoothAdapter.STATE_ON && try {
|
||||
val on = adapter.state == BluetoothAdapter.STATE_ON && try {
|
||||
pan.isTetheringOn
|
||||
} catch (e: InvocationTargetException) {
|
||||
activeFailureCause = e.cause ?: e
|
||||
@@ -96,16 +69,21 @@ class BluetoothTethering(context: Context, val stateListener: () -> Unit) :
|
||||
else Timber.w(e)
|
||||
return null
|
||||
}
|
||||
return if (stoppedByUser) {
|
||||
if (!on) stoppedByUser = false
|
||||
false
|
||||
} else on
|
||||
}
|
||||
|
||||
private val receiver = broadcastReceiver { _, _ -> stateListener() }
|
||||
|
||||
fun ensureInit(context: Context) {
|
||||
if (pan == null && BluetoothAdapter.getDefaultAdapter() != null) try {
|
||||
pan = pan(context, this)
|
||||
} catch (e: InvocationTargetException) {
|
||||
if (e.cause is SecurityException && BuildCompat.isAtLeastS()) Timber.d(e.readableMessage)
|
||||
else Timber.w(e)
|
||||
activeFailureCause = null
|
||||
if (!proxyCreated) try {
|
||||
check(adapter.getProfileProxy(context, this, PAN))
|
||||
proxyCreated = true
|
||||
} catch (e: SecurityException) {
|
||||
if (Build.VERSION.SDK_INT >= 31) Timber.d(e.readableMessage) else Timber.w(e)
|
||||
activeFailureCause = e
|
||||
}
|
||||
}
|
||||
@@ -116,13 +94,38 @@ class BluetoothTethering(context: Context, val stateListener: () -> Unit) :
|
||||
|
||||
override fun onServiceDisconnected(profile: Int) {
|
||||
connected = false
|
||||
stoppedByUser = false
|
||||
}
|
||||
override fun onServiceConnected(profile: Int, proxy: BluetoothProfile) {
|
||||
pan = proxy
|
||||
connected = true
|
||||
stateListener()
|
||||
}
|
||||
|
||||
/**
|
||||
* https://android.googlesource.com/platform/packages/apps/Settings/+/b1af85d/src/com/android/settings/TetherSettings.java#384
|
||||
*/
|
||||
@SuppressLint("MissingPermission")
|
||||
fun start(callback: TetheringManager.StartTetheringCallback, context: Context) {
|
||||
if (pendingCallback == null) try {
|
||||
if (adapter.state == BluetoothAdapter.STATE_OFF) {
|
||||
registerBluetoothStateListener(BluetoothTethering)
|
||||
pendingCallback = callback
|
||||
@Suppress("DEPRECATION")
|
||||
if (!adapter.enable()) context.startActivity(Intent(BluetoothAdapter.ACTION_REQUEST_ENABLE))
|
||||
} else TetheringManager.startTethering(TetheringManager.TETHERING_BLUETOOTH, true, callback)
|
||||
} catch (e: SecurityException) {
|
||||
SmartSnackbar.make(e.readableMessage).shortToast().show()
|
||||
pendingCallback = null
|
||||
}
|
||||
}
|
||||
fun stop(callback: (Exception) -> Unit) {
|
||||
TetheringManager.stopTethering(TetheringManager.TETHERING_BLUETOOTH, callback)
|
||||
stoppedByUser = true
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
app.unregisterReceiver(receiver)
|
||||
pan?.closePan()
|
||||
adapter.closeProfileProxy(PAN, pan)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ package be.mygod.vpnhotspot.manage
|
||||
|
||||
import android.content.Intent
|
||||
import android.view.View
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import be.mygod.vpnhotspot.R
|
||||
import be.mygod.vpnhotspot.TetheringService
|
||||
@@ -25,7 +24,7 @@ class InterfaceManager(private val parent: TetheringFragment, val iface: String)
|
||||
val data = binding.data as Data
|
||||
if (data.active) context.startService(Intent(context, TetheringService::class.java)
|
||||
.putExtra(TetheringService.EXTRA_REMOVE_INTERFACE, iface))
|
||||
else ContextCompat.startForegroundService(context, Intent(context, TetheringService::class.java)
|
||||
else context.startForegroundService(Intent(context, TetheringService::class.java)
|
||||
.putExtra(TetheringService.EXTRA_ADD_INTERFACES, arrayOf(iface)))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
package be.mygod.vpnhotspot.manage
|
||||
|
||||
import android.service.quicksettings.Tile
|
||||
import androidx.annotation.RequiresApi
|
||||
import be.mygod.vpnhotspot.R
|
||||
import be.mygod.vpnhotspot.net.IpNeighbour
|
||||
import be.mygod.vpnhotspot.net.monitor.IpNeighbourMonitor
|
||||
import be.mygod.vpnhotspot.util.KillableTileService
|
||||
import java.net.Inet4Address
|
||||
|
||||
@RequiresApi(24)
|
||||
abstract class IpNeighbourMonitoringTileService : KillableTileService(), IpNeighbourMonitor.Callback {
|
||||
private var neighbours: Collection<IpNeighbour> = emptyList()
|
||||
abstract fun updateTile()
|
||||
|
||||
@@ -1,15 +1,12 @@
|
||||
package be.mygod.vpnhotspot.manage
|
||||
|
||||
import android.Manifest
|
||||
import android.content.*
|
||||
import android.location.LocationManager
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.ServiceConnection
|
||||
import android.os.Build
|
||||
import android.os.IBinder
|
||||
import android.provider.Settings
|
||||
import android.view.View
|
||||
import android.widget.Toast
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.core.content.getSystemService
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import be.mygod.vpnhotspot.App.Companion.app
|
||||
import be.mygod.vpnhotspot.LocalOnlyHotspotService
|
||||
@@ -17,15 +14,15 @@ import be.mygod.vpnhotspot.R
|
||||
import be.mygod.vpnhotspot.databinding.ListitemInterfaceBinding
|
||||
import be.mygod.vpnhotspot.util.ServiceForegroundConnector
|
||||
import be.mygod.vpnhotspot.util.formatAddresses
|
||||
import be.mygod.vpnhotspot.widget.SmartSnackbar
|
||||
import java.net.NetworkInterface
|
||||
|
||||
@RequiresApi(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
|
||||
val permission = when {
|
||||
Build.VERSION.SDK_INT >= 33 -> Manifest.permission.NEARBY_WIFI_DEVICES
|
||||
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),
|
||||
@@ -57,23 +54,7 @@ class LocalOnlyHotspotManager(private val parent: TetheringFragment) : Manager()
|
||||
ServiceForegroundConnector(parent, this, LocalOnlyHotspotService::class)
|
||||
}
|
||||
|
||||
/**
|
||||
* LOH also requires location to be turned on. Source:
|
||||
* https://android.googlesource.com/platform/frameworks/opt/net/wifi/+/53e0284/service/java/com/android/server/wifi/WifiServiceImpl.java#1204
|
||||
* https://android.googlesource.com/platform/frameworks/opt/net/wifi/+/53e0284/service/java/com/android/server/wifi/WifiSettingsStore.java#228
|
||||
*/
|
||||
fun start(context: Context) {
|
||||
if (if (Build.VERSION.SDK_INT < 28) @Suppress("DEPRECATION") {
|
||||
Settings.Secure.getInt(context.contentResolver, Settings.Secure.LOCATION_MODE,
|
||||
Settings.Secure.LOCATION_MODE_OFF) == Settings.Secure.LOCATION_MODE_OFF
|
||||
} else context.getSystemService<LocationManager>()?.isLocationEnabled != true) try {
|
||||
context.startActivity(Intent(Settings.ACTION_LOCATION_SOURCE_SETTINGS))
|
||||
Toast.makeText(context, R.string.tethering_temp_hotspot_location, Toast.LENGTH_LONG).show()
|
||||
} catch (e: ActivityNotFoundException) {
|
||||
app.logEvent("location_settings") { param("message", e.toString()) }
|
||||
SmartSnackbar.make(R.string.tethering_temp_hotspot_location).show()
|
||||
} else context.startForegroundService(Intent(context, LocalOnlyHotspotService::class.java))
|
||||
}
|
||||
fun start(context: Context) = app.startServiceWithLocation<LocalOnlyHotspotService>(context)
|
||||
|
||||
override val type get() = VIEW_TYPE_LOCAL_ONLY_HOTSPOT
|
||||
private val data = Data()
|
||||
|
||||
@@ -6,12 +6,10 @@ import android.content.Intent
|
||||
import android.graphics.drawable.Icon
|
||||
import android.os.IBinder
|
||||
import android.service.quicksettings.Tile
|
||||
import androidx.annotation.RequiresApi
|
||||
import be.mygod.vpnhotspot.LocalOnlyHotspotService
|
||||
import be.mygod.vpnhotspot.R
|
||||
import be.mygod.vpnhotspot.util.stopAndUnbind
|
||||
|
||||
@RequiresApi(26)
|
||||
class LocalOnlyHotspotTileService : IpNeighbourMonitoringTileService() {
|
||||
private val tile by lazy { Icon.createWithResource(application, R.drawable.ic_action_perm_scan_wifi) }
|
||||
|
||||
@@ -38,7 +36,7 @@ class LocalOnlyHotspotTileService : IpNeighbourMonitoringTileService() {
|
||||
label = getText(R.string.tethering_temp_hotspot)
|
||||
} else {
|
||||
state = Tile.STATE_ACTIVE
|
||||
label = binder.configuration?.ssid ?: getText(R.string.tethering_temp_hotspot)
|
||||
label = binder.configuration?.ssid?.toString() ?: getText(R.string.tethering_temp_hotspot)
|
||||
subtitleDevices { it == iface }
|
||||
}
|
||||
updateTile()
|
||||
|
||||
@@ -16,7 +16,7 @@ object ManageBar : Manager() {
|
||||
private const val SETTINGS_2 = "com.android.settings.TetherSettings"
|
||||
|
||||
object Data : BaseObservable() {
|
||||
val offloadEnabled get() = TetherOffloadManager.supported && TetherOffloadManager.enabled
|
||||
val offloadEnabled get() = TetherOffloadManager.enabled
|
||||
}
|
||||
class ViewHolder(binding: ListitemManageBinding) : RecyclerView.ViewHolder(binding.root), View.OnClickListener {
|
||||
init {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package be.mygod.vpnhotspot.manage
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.annotation.TargetApi
|
||||
import android.view.LayoutInflater
|
||||
import android.view.ViewGroup
|
||||
import androidx.recyclerview.widget.DiffUtil
|
||||
@@ -18,9 +17,6 @@ abstract class Manager {
|
||||
const val VIEW_TYPE_USB = 3
|
||||
const val VIEW_TYPE_BLUETOOTH = 4
|
||||
const val VIEW_TYPE_ETHERNET = 8
|
||||
const val VIEW_TYPE_NCM = 9
|
||||
const val VIEW_TYPE_WIGIG = 10
|
||||
const val VIEW_TYPE_WIFI_LEGACY = 5
|
||||
const val VIEW_TYPE_LOCAL_ONLY_HOTSPOT = 6
|
||||
const val VIEW_TYPE_REPEATER = 7
|
||||
|
||||
@@ -35,13 +31,10 @@ abstract class Manager {
|
||||
VIEW_TYPE_WIFI,
|
||||
VIEW_TYPE_USB,
|
||||
VIEW_TYPE_BLUETOOTH,
|
||||
VIEW_TYPE_ETHERNET,
|
||||
VIEW_TYPE_NCM,
|
||||
VIEW_TYPE_WIGIG,
|
||||
VIEW_TYPE_WIFI_LEGACY -> {
|
||||
VIEW_TYPE_ETHERNET -> {
|
||||
TetherManager.ViewHolder(ListitemInterfaceBinding.inflate(inflater, parent, false))
|
||||
}
|
||||
VIEW_TYPE_LOCAL_ONLY_HOTSPOT -> @TargetApi(26) {
|
||||
VIEW_TYPE_LOCAL_ONLY_HOTSPOT -> {
|
||||
LocalOnlyHotspotManager.ViewHolder(ListitemInterfaceBinding.inflate(inflater, parent, false))
|
||||
}
|
||||
VIEW_TYPE_REPEATER -> RepeaterManager.ViewHolder(ListitemRepeaterBinding.inflate(inflater, parent, false))
|
||||
|
||||
@@ -5,7 +5,7 @@ import android.content.ComponentName
|
||||
import android.content.DialogInterface
|
||||
import android.content.Intent
|
||||
import android.content.ServiceConnection
|
||||
import android.content.pm.PackageManager
|
||||
import android.net.MacAddress
|
||||
import android.net.wifi.SoftApConfiguration
|
||||
import android.net.wifi.p2p.WifiP2pGroup
|
||||
import android.os.Build
|
||||
@@ -17,25 +17,33 @@ import android.view.WindowManager
|
||||
import android.widget.EditText
|
||||
import androidx.annotation.MainThread
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.databinding.BaseObservable
|
||||
import androidx.databinding.Bindable
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.lifecycle.withStarted
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import be.mygod.vpnhotspot.*
|
||||
import be.mygod.vpnhotspot.AlertDialogFragment
|
||||
import be.mygod.vpnhotspot.BR
|
||||
import be.mygod.vpnhotspot.Empty
|
||||
import be.mygod.vpnhotspot.R
|
||||
import be.mygod.vpnhotspot.RepeaterService
|
||||
import be.mygod.vpnhotspot.databinding.ListitemRepeaterBinding
|
||||
import be.mygod.vpnhotspot.net.MacAddressCompat
|
||||
import be.mygod.vpnhotspot.net.wifi.P2pSupplicantConfiguration
|
||||
import be.mygod.vpnhotspot.net.wifi.SoftApConfigurationCompat
|
||||
import be.mygod.vpnhotspot.net.wifi.WifiApDialogFragment
|
||||
import be.mygod.vpnhotspot.net.wifi.WifiApManager
|
||||
import be.mygod.vpnhotspot.net.wifi.WifiSsidCompat
|
||||
import be.mygod.vpnhotspot.util.ServiceForegroundConnector
|
||||
import be.mygod.vpnhotspot.util.formatAddresses
|
||||
import be.mygod.vpnhotspot.util.showAllowingStateLoss
|
||||
import be.mygod.vpnhotspot.widget.SmartSnackbar
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import timber.log.Timber
|
||||
import java.net.NetworkInterface
|
||||
@@ -71,6 +79,9 @@ class RepeaterManager(private val parent: TetheringFragment) : Manager(), Servic
|
||||
NetworkInterface.getByName(p2pInterface ?: return "")?.formatAddresses() ?: ""
|
||||
} catch (_: SocketException) {
|
||||
""
|
||||
} catch (e: Exception) {
|
||||
Timber.w(e)
|
||||
""
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,12 +100,10 @@ class RepeaterManager(private val parent: TetheringFragment) : Manager(), Servic
|
||||
val binder = binder
|
||||
when (binder?.service?.status) {
|
||||
RepeaterService.Status.IDLE -> if (Build.VERSION.SDK_INT < 29) parent.requireContext().let { context ->
|
||||
ContextCompat.startForegroundService(context, Intent(context, RepeaterService::class.java))
|
||||
} else if (parent.requireContext().checkSelfPermission(Manifest.permission.ACCESS_FINE_LOCATION) ==
|
||||
PackageManager.PERMISSION_GRANTED ||
|
||||
parent.shouldShowRequestPermissionRationale(Manifest.permission.ACCESS_FINE_LOCATION)) {
|
||||
parent.startRepeater.launch(Manifest.permission.ACCESS_FINE_LOCATION)
|
||||
} else SmartSnackbar.make(R.string.repeater_missing_location_permissions).shortToast().show()
|
||||
context.startForegroundService(Intent(context, RepeaterService::class.java))
|
||||
} else parent.startRepeater.launch(if (Build.VERSION.SDK_INT >= 33) {
|
||||
Manifest.permission.NEARBY_WIFI_DEVICES
|
||||
} else Manifest.permission.ACCESS_FINE_LOCATION)
|
||||
RepeaterService.Status.ACTIVE -> binder.shutdown()
|
||||
else -> { }
|
||||
}
|
||||
@@ -148,8 +157,10 @@ class RepeaterManager(private val parent: TetheringFragment) : Manager(), Servic
|
||||
fun configure() {
|
||||
if (configuring) return
|
||||
configuring = true
|
||||
parent.viewLifecycleOwner.lifecycleScope.launchWhenCreated {
|
||||
getConfiguration()?.let { (config, readOnly) ->
|
||||
val owner = parent.viewLifecycleOwner
|
||||
owner.lifecycleScope.launch {
|
||||
val (config, readOnly) = getConfiguration() ?: return@launch
|
||||
owner.withStarted {
|
||||
WifiApDialogFragment().apply {
|
||||
arg(WifiApDialogFragment.Arg(config, readOnly, true))
|
||||
key(this@RepeaterManager.javaClass.name)
|
||||
@@ -195,21 +206,29 @@ class RepeaterManager(private val parent: TetheringFragment) : Manager(), Servic
|
||||
passphrase = passphrase,
|
||||
securityType = SoftApConfiguration.SECURITY_TYPE_WPA2_PSK, // is not actually used
|
||||
isAutoShutdownEnabled = RepeaterService.isAutoShutdownEnabled,
|
||||
shutdownTimeoutMillis = RepeaterService.shutdownTimeoutMillis).apply {
|
||||
shutdownTimeoutMillis = RepeaterService.shutdownTimeoutMillis,
|
||||
macRandomizationSetting = if (WifiApManager.p2pMacRandomizationSupported) {
|
||||
SoftApConfigurationCompat.RANDOMIZATION_NON_PERSISTENT
|
||||
} else SoftApConfigurationCompat.RANDOMIZATION_NONE,
|
||||
vendorElements = RepeaterService.vendorElements,
|
||||
).apply {
|
||||
bssid = RepeaterService.deviceAddress
|
||||
setChannel(RepeaterService.operatingChannel, RepeaterService.operatingBand)
|
||||
setMacRandomizationEnabled(WifiApManager.p2pMacRandomizationSupported)
|
||||
} to false
|
||||
}
|
||||
} else binder?.let { binder ->
|
||||
val group = binder.group ?: binder.fetchPersistentGroup().let { binder.group }
|
||||
if (group != null) return SoftApConfigurationCompat(
|
||||
ssid = group.networkName,
|
||||
ssid = WifiSsidCompat.fromUtf8Text(group.networkName),
|
||||
securityType = SoftApConfiguration.SECURITY_TYPE_WPA2_PSK, // is not actually used
|
||||
isAutoShutdownEnabled = RepeaterService.isAutoShutdownEnabled,
|
||||
shutdownTimeoutMillis = RepeaterService.shutdownTimeoutMillis).run {
|
||||
shutdownTimeoutMillis = RepeaterService.shutdownTimeoutMillis,
|
||||
macRandomizationSetting = if (WifiApManager.p2pMacRandomizationSupported) {
|
||||
SoftApConfigurationCompat.RANDOMIZATION_NON_PERSISTENT
|
||||
} else SoftApConfigurationCompat.RANDOMIZATION_NONE,
|
||||
vendorElements = RepeaterService.vendorElements,
|
||||
).run {
|
||||
setChannel(RepeaterService.operatingChannel)
|
||||
setMacRandomizationEnabled(WifiApManager.p2pMacRandomizationSupported)
|
||||
try {
|
||||
val config = P2pSupplicantConfiguration(group)
|
||||
config.init(binder.obtainDeviceAddress()?.toString())
|
||||
@@ -221,7 +240,7 @@ class RepeaterManager(private val parent: TetheringFragment) : Manager(), Servic
|
||||
if (e !is CancellationException) Timber.w(e)
|
||||
passphrase = group.passphrase
|
||||
try {
|
||||
bssid = group.owner?.deviceAddress?.let(MacAddressCompat.Companion::fromString)
|
||||
bssid = group.owner?.deviceAddress?.let(MacAddress::fromString)
|
||||
} catch (_: IllegalArgumentException) { }
|
||||
this to true
|
||||
}
|
||||
@@ -231,15 +250,19 @@ class RepeaterManager(private val parent: TetheringFragment) : Manager(), Servic
|
||||
return null
|
||||
}
|
||||
private suspend fun updateConfiguration(config: SoftApConfigurationCompat) {
|
||||
val (band, channel) = config.requireSingleBand()
|
||||
val (band, channel) = SoftApConfigurationCompat.requireSingleBand(config.channels)
|
||||
if (RepeaterService.safeMode) {
|
||||
RepeaterService.networkName = config.ssid
|
||||
RepeaterService.deviceAddress = config.bssid
|
||||
RepeaterService.passphrase = config.passphrase
|
||||
} else holder.config?.let { master ->
|
||||
val binder = binder
|
||||
if (binder?.group?.networkName != config.ssid || master.psk != config.passphrase ||
|
||||
master.bssid != config.bssid) try {
|
||||
val mayBeModified = master.psk != config.passphrase || master.bssid != config.bssid || config.ssid.run {
|
||||
if (this != null) decode().let {
|
||||
it == null || binder?.group?.networkName != it
|
||||
} else binder?.group?.networkName != null
|
||||
}
|
||||
if (mayBeModified) try {
|
||||
withContext(Dispatchers.Default) { master.update(config.ssid!!, config.passphrase!!, config.bssid) }
|
||||
(this.binder ?: binder)?.group = null
|
||||
} catch (e: Exception) {
|
||||
@@ -252,5 +275,6 @@ class RepeaterManager(private val parent: TetheringFragment) : Manager(), Servic
|
||||
RepeaterService.operatingChannel = channel
|
||||
RepeaterService.isAutoShutdownEnabled = config.isAutoShutdownEnabled
|
||||
RepeaterService.shutdownTimeoutMillis = config.shutdownTimeoutMillis
|
||||
RepeaterService.vendorElements = config.vendorElements
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,15 +7,12 @@ import android.graphics.drawable.Icon
|
||||
import android.net.wifi.p2p.WifiP2pGroup
|
||||
import android.os.IBinder
|
||||
import android.service.quicksettings.Tile
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.core.content.ContextCompat
|
||||
import be.mygod.vpnhotspot.R
|
||||
import be.mygod.vpnhotspot.RepeaterService
|
||||
import be.mygod.vpnhotspot.util.KillableTileService
|
||||
import be.mygod.vpnhotspot.util.Services
|
||||
import be.mygod.vpnhotspot.util.stopAndUnbind
|
||||
|
||||
@RequiresApi(24)
|
||||
class RepeaterTileService : KillableTileService() {
|
||||
private val tile by lazy { Icon.createWithResource(application, R.drawable.ic_action_settings_input_antenna) }
|
||||
|
||||
@@ -37,8 +34,7 @@ class RepeaterTileService : KillableTileService() {
|
||||
val binder = binder
|
||||
if (binder == null) tapPending = true else when (binder.service.status) {
|
||||
RepeaterService.Status.ACTIVE -> binder.shutdown()
|
||||
RepeaterService.Status.IDLE -> ContextCompat.startForegroundService(this,
|
||||
Intent(this, RepeaterService::class.java))
|
||||
RepeaterService.Status.IDLE -> startForegroundService(Intent(this, RepeaterService::class.java))
|
||||
else -> { }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ package be.mygod.vpnhotspot.manage
|
||||
|
||||
import android.Manifest
|
||||
import android.annotation.TargetApi
|
||||
import android.content.ClipData
|
||||
import android.bluetooth.BluetoothAdapter
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
@@ -15,7 +15,6 @@ import android.view.View
|
||||
import android.widget.Toast
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.core.net.toUri
|
||||
import androidx.core.os.BuildCompat
|
||||
import androidx.core.view.updatePaddingRelative
|
||||
import androidx.lifecycle.DefaultLifecycleObserver
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
@@ -56,19 +55,23 @@ sealed class TetherManager(protected val parent: TetheringFragment) : Manager(),
|
||||
override fun onClick(v: View?) {
|
||||
val manager = manager!!
|
||||
val mainActivity = manager.parent.activity as MainActivity
|
||||
if (Build.VERSION.SDK_INT >= 23 && !Settings.System.canWrite(mainActivity)) try {
|
||||
if (!Settings.System.canWrite(mainActivity)) try {
|
||||
manager.parent.startActivity(Intent(Settings.ACTION_MANAGE_WRITE_SETTINGS,
|
||||
"package:${mainActivity.packageName}".toUri()))
|
||||
return
|
||||
} catch (e: RuntimeException) {
|
||||
app.logEvent("manage_write_settings") { param("message", e.toString()) }
|
||||
}
|
||||
if (manager.isStarted) try {
|
||||
when (manager.isStarted) {
|
||||
true -> try {
|
||||
manager.stop()
|
||||
} catch (e: InvocationTargetException) {
|
||||
if (e.targetException !is SecurityException) Timber.w(e)
|
||||
manager.onException(e)
|
||||
} else manager.start()
|
||||
}
|
||||
false -> manager.start()
|
||||
null -> manager.onClickNull()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,13 +82,14 @@ sealed class TetherManager(protected val parent: TetheringFragment) : Manager(),
|
||||
override val icon get() = tetherType.icon
|
||||
override val title get() = this@TetherManager.title
|
||||
override val text get() = this@TetherManager.text
|
||||
override val active get() = isStarted
|
||||
override val active get() = isStarted == true
|
||||
}
|
||||
|
||||
val data = Data()
|
||||
abstract val title: CharSequence
|
||||
abstract val tetherType: TetherType
|
||||
open val isStarted get() = parent.enabledTypes.contains(tetherType)
|
||||
open val isStarted: Boolean? get() = parent.enabledTypes.contains(tetherType) ||
|
||||
tetherType == TetherType.USB && parent.enabledTypes.contains(TetherType.NCM)
|
||||
protected open val text: CharSequence get() = baseError ?: ""
|
||||
|
||||
protected var baseError: String? = null
|
||||
@@ -93,6 +97,7 @@ sealed class TetherManager(protected val parent: TetheringFragment) : Manager(),
|
||||
|
||||
protected abstract fun start()
|
||||
protected abstract fun stop()
|
||||
protected open fun onClickNull(): Unit = throw UnsupportedOperationException()
|
||||
|
||||
override fun onTetheringStarted() = data.notifyChange()
|
||||
override fun onTetheringFailed(error: Int?) {
|
||||
@@ -120,21 +125,20 @@ sealed class TetherManager(protected val parent: TetheringFragment) : Manager(),
|
||||
}
|
||||
|
||||
fun updateErrorMessage(errored: List<String>, lastErrors: Map<String, Int>) {
|
||||
val interested = errored.filter { TetherType.ofInterface(it) == tetherType }
|
||||
val interested = errored.filter { TetherType.ofInterface(it).isA(tetherType) }
|
||||
baseError = if (interested.isEmpty()) null else interested.joinToString("\n") { iface ->
|
||||
"$iface: " + try {
|
||||
TetheringManager.tetherErrorLookup(if (Build.VERSION.SDK_INT < 30) @Suppress("DEPRECATION") {
|
||||
TetheringManager.getLastTetherError(iface)
|
||||
} else lastErrors[iface] ?: 0)
|
||||
} catch (e: InvocationTargetException) {
|
||||
if (Build.VERSION.SDK_INT !in 24..25 || e.cause !is SecurityException) Timber.w(e) else Timber.d(e)
|
||||
if (e.cause !is SecurityException) Timber.w(e) else Timber.d(e)
|
||||
e.readableMessage
|
||||
}
|
||||
}
|
||||
data.notifyChange()
|
||||
}
|
||||
|
||||
@RequiresApi(24)
|
||||
class Wifi(parent: TetheringFragment) : TetherManager(parent), DefaultLifecycleObserver,
|
||||
WifiApManager.SoftApCallbackCompat {
|
||||
private var failureReason: Int? = null
|
||||
@@ -143,24 +147,19 @@ sealed class TetherManager(protected val parent: TetheringFragment) : Manager(),
|
||||
private var capability: Parcelable? = null
|
||||
|
||||
init {
|
||||
if (Build.VERSION.SDK_INT >= 28) parent.viewLifecycleOwner.lifecycle.addObserver(this)
|
||||
parent.viewLifecycleOwner.lifecycle.addObserver(this)
|
||||
}
|
||||
|
||||
@TargetApi(28)
|
||||
override fun onStart(owner: LifecycleOwner) {
|
||||
WifiApCommands.registerSoftApCallback(this)
|
||||
}
|
||||
@TargetApi(28)
|
||||
override fun onStop(owner: LifecycleOwner) {
|
||||
WifiApCommands.unregisterSoftApCallback(this)
|
||||
}
|
||||
|
||||
override fun onStateChanged(state: Int, failureReason: Int) {
|
||||
if (state < 10 || state > 14) {
|
||||
Timber.w(Exception("Unknown state $state, $failureReason"))
|
||||
return
|
||||
}
|
||||
this.failureReason = if (state == 14) failureReason else null // WIFI_AP_STATE_FAILED
|
||||
if (!WifiApManager.checkWifiApState(state)) return
|
||||
this.failureReason = if (state == WifiApManager.WIFI_AP_STATE_FAILED) failureReason else null
|
||||
data.notifyChange()
|
||||
}
|
||||
override fun onNumClientsChanged(numClients: Int) {
|
||||
@@ -175,21 +174,6 @@ sealed class TetherManager(protected val parent: TetheringFragment) : Manager(),
|
||||
this.capability = capability
|
||||
data.notifyChange()
|
||||
}
|
||||
@RequiresApi(30)
|
||||
override fun onBlockedClientConnecting(client: Parcelable, blockedReason: Int) {
|
||||
@Suppress("NAME_SHADOWING")
|
||||
val client = WifiClient(client)
|
||||
val macAddress = client.macAddress
|
||||
var name = macAddress.toString()
|
||||
if (BuildCompat.isAtLeastS()) client.apInstanceIdentifier?.let { name += "%$it" }
|
||||
val reason = WifiApManager.clientBlockLookup(blockedReason, true)
|
||||
Timber.i("$name blocked from connecting: $reason ($blockedReason)")
|
||||
SmartSnackbar.make(parent.getString(R.string.tethering_manage_wifi_client_blocked, name, reason)).apply {
|
||||
action(R.string.tethering_manage_wifi_copy_mac) {
|
||||
app.clipboard.setPrimaryClip(ClipData.newPlainText(null, macAddress.toString()))
|
||||
}
|
||||
}.show()
|
||||
}
|
||||
|
||||
override val title get() = parent.getString(R.string.tethering_manage_wifi)
|
||||
override val tetherType get() = TetherType.WIFI
|
||||
@@ -201,7 +185,7 @@ sealed class TetherManager(protected val parent: TetheringFragment) : Manager(),
|
||||
val numClients = numClients
|
||||
val maxClients = capability.maxSupportedClients
|
||||
var features = capability.supportedFeatures
|
||||
if (BuildCompat.isAtLeastS()) for ((flag, band) in arrayOf(
|
||||
if (Build.VERSION.SDK_INT >= 31) for ((flag, band) in arrayOf(
|
||||
SoftApCapability.SOFTAP_FEATURE_BAND_24G_SUPPORTED to SoftApConfigurationCompat.BAND_2GHZ,
|
||||
SoftApCapability.SOFTAP_FEATURE_BAND_5G_SUPPORTED to SoftApConfigurationCompat.BAND_5GHZ,
|
||||
SoftApCapability.SOFTAP_FEATURE_BAND_6G_SUPPORTED to SoftApConfigurationCompat.BAND_6GHZ,
|
||||
@@ -217,7 +201,7 @@ sealed class TetherManager(protected val parent: TetheringFragment) : Manager(),
|
||||
R.string.tethering_manage_wifi_feature_ap_mac_randomization))
|
||||
if (Services.wifi.isStaApConcurrencySupported) yield(parent.getText(
|
||||
R.string.tethering_manage_wifi_feature_sta_ap_concurrency))
|
||||
if (BuildCompat.isAtLeastS()) {
|
||||
if (Build.VERSION.SDK_INT >= 31) {
|
||||
if (Services.wifi.isBridgedApConcurrencySupported) yield(parent.getText(
|
||||
R.string.tethering_manage_wifi_feature_bridged_ap_concurrency))
|
||||
if (Services.wifi.isStaBridgedApConcurrencySupported) yield(parent.getText(
|
||||
@@ -228,63 +212,46 @@ sealed class TetherManager(protected val parent: TetheringFragment) : Manager(),
|
||||
yield(SoftApCapability.featureLookup(bit, true))
|
||||
features = features and bit.inv()
|
||||
}
|
||||
}.joinToSpanned().let {
|
||||
if (it.isEmpty()) parent.getText(R.string.tethering_manage_wifi_no_features) else it
|
||||
})
|
||||
if (BuildCompat.isAtLeastS()) {
|
||||
}.joinToSpanned().ifEmpty { parent.getText(R.string.tethering_manage_wifi_no_features) })
|
||||
if (Build.VERSION.SDK_INT >= 31) {
|
||||
val list = SoftApConfigurationCompat.BAND_TYPES.map { band ->
|
||||
val channels = capability.getSupportedChannelList(band)
|
||||
if (channels.isNotEmpty()) StringBuilder().apply {
|
||||
append(SoftApConfigurationCompat.bandLookup(band, true))
|
||||
append(" (")
|
||||
channels.sort()
|
||||
var pending: Int? = null
|
||||
var last = channels[0]
|
||||
append(last)
|
||||
for (channel in channels.asSequence().drop(1)) {
|
||||
if (channel == last + 1) pending = channel else {
|
||||
pending?.let {
|
||||
append('-')
|
||||
append(it)
|
||||
pending = null
|
||||
}
|
||||
append(',')
|
||||
append(channel)
|
||||
}
|
||||
last = channel
|
||||
}
|
||||
pending?.let {
|
||||
append('-')
|
||||
append(it)
|
||||
}
|
||||
append(')')
|
||||
if (channels.isNotEmpty()) {
|
||||
"${SoftApConfigurationCompat.bandLookup(band, true)} (${RangeInput.toString(channels)})"
|
||||
} else null
|
||||
}.filterNotNull()
|
||||
if (list.isNotEmpty()) result.append(parent.getText(R.string.tethering_manage_wifi_supported_channels)
|
||||
.format(locale, list.joinToString("; ")))
|
||||
capability.countryCode?.let {
|
||||
result.append(parent.getText(R.string.tethering_manage_wifi_country_code).format(locale, it))
|
||||
}
|
||||
}
|
||||
result
|
||||
} ?: numClients?.let { numClients ->
|
||||
app.resources.getQuantityText(R.plurals.tethering_manage_wifi_clients, numClients).format(locale,
|
||||
numClients)
|
||||
}
|
||||
override val text get() = parent.resources.configuration.locale.let { locale ->
|
||||
override val text get() = parent.resources.configuration.locales[0].let { locale ->
|
||||
listOfNotNull(failureReason?.let { WifiApManager.failureReasonLookup(it) }, baseError, info.run {
|
||||
if (isEmpty()) null else joinToSpanned("\n") @TargetApi(30) { parcel ->
|
||||
val info = SoftApInfo(parcel)
|
||||
val frequency = info.frequency
|
||||
val channel = SoftApConfigurationCompat.frequencyToChannel(frequency)
|
||||
val bandwidth = SoftApInfo.channelWidthLookup(info.bandwidth, true)
|
||||
if (BuildCompat.isAtLeastS()) {
|
||||
var bssid = makeMacSpan(info.bssid.toString())
|
||||
info.apInstanceIdentifier?.let { // take the fast route if possible
|
||||
bssid = if (bssid is String) "$bssid%$it" else SpannableStringBuilder(bssid).append("%$it")
|
||||
if (Build.VERSION.SDK_INT >= 31) {
|
||||
val bssid = info.bssid.let { if (it == null) null else makeMacSpan(it.toString()) }
|
||||
val bssidAp = info.apInstanceIdentifier?.let {
|
||||
when (bssid) {
|
||||
null -> it
|
||||
is String -> "$bssid%$it" // take the fast route if possible
|
||||
else -> SpannableStringBuilder(bssid).append("%$it")
|
||||
}
|
||||
} ?: bssid ?: "?"
|
||||
val timeout = info.autoShutdownTimeoutMillis
|
||||
parent.getText(if (timeout == 0L) {
|
||||
R.string.tethering_manage_wifi_info_timeout_disabled
|
||||
} else R.string.tethering_manage_wifi_info_timeout_enabled).format(locale,
|
||||
frequency, channel, bandwidth, bssid, info.wifiStandard,
|
||||
frequency, channel, bandwidth, bssidAp, info.wifiStandard,
|
||||
// http://unicode.org/cldr/trac/ticket/3407
|
||||
DateUtils.formatElapsedTime(timeout / 1000))
|
||||
} else parent.getText(R.string.tethering_manage_wifi_info).format(locale,
|
||||
@@ -296,7 +263,6 @@ sealed class TetherManager(protected val parent: TetheringFragment) : Manager(),
|
||||
override fun start() = TetheringManager.startTethering(TetheringManager.TETHERING_WIFI, true, this)
|
||||
override fun stop() = TetheringManager.stopTethering(TetheringManager.TETHERING_WIFI, this::onException)
|
||||
}
|
||||
@RequiresApi(24)
|
||||
class Usb(parent: TetheringFragment) : TetherManager(parent) {
|
||||
override val title get() = parent.getString(R.string.tethering_manage_usb)
|
||||
override val tetherType get() = TetherType.USB
|
||||
@@ -305,39 +271,41 @@ sealed class TetherManager(protected val parent: TetheringFragment) : Manager(),
|
||||
override fun start() = TetheringManager.startTethering(TetheringManager.TETHERING_USB, true, this)
|
||||
override fun stop() = TetheringManager.stopTethering(TetheringManager.TETHERING_USB, this::onException)
|
||||
}
|
||||
@RequiresApi(24)
|
||||
class Bluetooth(parent: TetheringFragment) : TetherManager(parent), DefaultLifecycleObserver {
|
||||
private val tethering = BluetoothTethering(parent.requireContext()) { data.notifyChange() }
|
||||
class Bluetooth(parent: TetheringFragment, adapter: BluetoothAdapter) :
|
||||
TetherManager(parent), DefaultLifecycleObserver {
|
||||
private val tethering = BluetoothTethering(parent.requireContext(), adapter) { data.notifyChange() }
|
||||
|
||||
init {
|
||||
parent.viewLifecycleOwner.lifecycle.addObserver(this)
|
||||
}
|
||||
|
||||
fun ensureInit(context: Context) = tethering.ensureInit(context)
|
||||
override fun onResume(owner: LifecycleOwner) {
|
||||
if (!BuildCompat.isAtLeastS() || parent.requireContext().checkSelfPermission(
|
||||
Manifest.permission.BLUETOOTH_CONNECT) == PackageManager.PERMISSION_GRANTED) {
|
||||
ensureInit(parent.requireContext())
|
||||
} else if (parent.shouldShowRequestPermissionRationale(Manifest.permission.BLUETOOTH_CONNECT)) {
|
||||
parent.requestBluetooth.launch(Manifest.permission.BLUETOOTH_CONNECT)
|
||||
fun ensureInit(context: Context) {
|
||||
tethering.ensureInit(context)
|
||||
onTetheringStarted() // force flush
|
||||
}
|
||||
override fun onResume(owner: LifecycleOwner) {
|
||||
if (Build.VERSION.SDK_INT < 31) return
|
||||
if (parent.requireContext().checkSelfPermission(Manifest.permission.BLUETOOTH_CONNECT) ==
|
||||
PackageManager.PERMISSION_GRANTED) {
|
||||
tethering.ensureInit(parent.requireContext())
|
||||
} else parent.requestBluetooth.launch(Manifest.permission.BLUETOOTH_CONNECT)
|
||||
}
|
||||
override fun onDestroy(owner: LifecycleOwner) = tethering.close()
|
||||
|
||||
override val title get() = parent.getString(R.string.tethering_manage_bluetooth)
|
||||
override val tetherType get() = TetherType.BLUETOOTH
|
||||
override val type get() = VIEW_TYPE_BLUETOOTH
|
||||
override val isStarted get() = tethering.active == true
|
||||
override val isStarted get() = tethering.active
|
||||
override val text get() = listOfNotNull(
|
||||
if (tethering.active == null) tethering.activeFailureCause?.readableMessage else null,
|
||||
baseError).joinToString("\n")
|
||||
|
||||
override fun start() = BluetoothTethering.start(this)
|
||||
override fun start() = tethering.start(this, parent.requireContext())
|
||||
override fun stop() {
|
||||
TetheringManager.stopTethering(TetheringManager.TETHERING_BLUETOOTH, this::onException)
|
||||
Thread.sleep(1) // give others a room to breathe
|
||||
tethering.stop(this::onException)
|
||||
onTetheringStarted() // force flush state
|
||||
}
|
||||
override fun onClickNull() = ManageBar.start(parent.requireContext())
|
||||
}
|
||||
@RequiresApi(30)
|
||||
class Ethernet(parent: TetheringFragment) : TetherManager(parent) {
|
||||
@@ -348,41 +316,4 @@ sealed class TetherManager(protected val parent: TetheringFragment) : Manager(),
|
||||
override fun start() = TetheringManager.startTethering(TetheringManager.TETHERING_ETHERNET, true, this)
|
||||
override fun stop() = TetheringManager.stopTethering(TetheringManager.TETHERING_ETHERNET, this::onException)
|
||||
}
|
||||
@RequiresApi(30)
|
||||
class Ncm(parent: TetheringFragment) : TetherManager(parent) {
|
||||
override val title get() = parent.getString(R.string.tethering_manage_ncm)
|
||||
override val tetherType get() = TetherType.NCM
|
||||
override val type get() = VIEW_TYPE_NCM
|
||||
|
||||
override fun start() = TetheringManager.startTethering(TetheringManager.TETHERING_NCM, true, this)
|
||||
override fun stop() = TetheringManager.stopTethering(TetheringManager.TETHERING_NCM, this::onException)
|
||||
}
|
||||
@RequiresApi(30)
|
||||
class WiGig(parent: TetheringFragment) : TetherManager(parent) {
|
||||
override val title get() = parent.getString(R.string.tethering_manage_wigig)
|
||||
override val tetherType get() = TetherType.WIGIG
|
||||
override val type get() = VIEW_TYPE_WIGIG
|
||||
|
||||
override fun start() = TetheringManager.startTethering(TetheringManager.TETHERING_WIGIG, true, this)
|
||||
override fun stop() = TetheringManager.stopTethering(TetheringManager.TETHERING_WIGIG, this::onException)
|
||||
}
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
@Deprecated("Not usable since API 26, malfunctioning on API 25")
|
||||
class WifiLegacy(parent: TetheringFragment) : TetherManager(parent) {
|
||||
override val title get() = parent.getString(R.string.tethering_manage_wifi_legacy)
|
||||
override val tetherType get() = TetherType.WIFI
|
||||
override val type get() = VIEW_TYPE_WIFI_LEGACY
|
||||
|
||||
override fun start() = try {
|
||||
WifiApManager.start()
|
||||
} catch (e: Exception) {
|
||||
onException(e)
|
||||
}
|
||||
override fun stop() = try {
|
||||
WifiApManager.stop()
|
||||
} catch (e: Exception) {
|
||||
onException(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
@file:Suppress("DEPRECATION")
|
||||
|
||||
package be.mygod.vpnhotspot.manage
|
||||
|
||||
import android.annotation.TargetApi
|
||||
import android.bluetooth.BluetoothManager
|
||||
import android.content.*
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
@@ -14,28 +13,35 @@ import android.view.ViewGroup
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.appcompat.widget.Toolbar
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.content.getSystemService
|
||||
import androidx.fragment.app.Fragment
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.lifecycle.withStarted
|
||||
import androidx.recyclerview.widget.DefaultItemAnimator
|
||||
import androidx.recyclerview.widget.LinearLayoutManager
|
||||
import androidx.recyclerview.widget.ListAdapter
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import be.mygod.vpnhotspot.*
|
||||
import be.mygod.vpnhotspot.App.Companion.app
|
||||
import be.mygod.vpnhotspot.databinding.FragmentTetheringBinding
|
||||
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.monitor.TetherTimeoutMonitor
|
||||
import be.mygod.vpnhotspot.net.wifi.SoftApConfigurationCompat
|
||||
import be.mygod.vpnhotspot.net.wifi.SoftApConfigurationCompat.Companion.toCompat
|
||||
import be.mygod.vpnhotspot.net.wifi.WifiApDialogFragment
|
||||
import be.mygod.vpnhotspot.net.wifi.WifiApManager
|
||||
import be.mygod.vpnhotspot.root.RootManager
|
||||
import be.mygod.vpnhotspot.root.WifiApCommands
|
||||
import be.mygod.vpnhotspot.util.*
|
||||
import be.mygod.vpnhotspot.widget.SmartSnackbar
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.launch
|
||||
import timber.log.Timber
|
||||
import java.lang.reflect.InvocationTargetException
|
||||
import java.net.NetworkInterface
|
||||
@@ -45,28 +51,29 @@ class TetheringFragment : Fragment(), ServiceConnection, Toolbar.OnMenuItemClick
|
||||
inner class ManagerAdapter : ListAdapter<Manager, RecyclerView.ViewHolder>(Manager),
|
||||
TetheringManager.TetheringEventCallback {
|
||||
internal val repeaterManager by lazy { RepeaterManager(this@TetheringFragment) }
|
||||
@get:RequiresApi(26)
|
||||
internal val localOnlyHotspotManager by lazy @TargetApi(26) { LocalOnlyHotspotManager(this@TetheringFragment) }
|
||||
@get:RequiresApi(24)
|
||||
internal val bluetoothManager by lazy @TargetApi(24) { TetherManager.Bluetooth(this@TetheringFragment) }
|
||||
@get:RequiresApi(24)
|
||||
private val tetherManagers by lazy @TargetApi(24) {
|
||||
listOf(TetherManager.Wifi(this@TetheringFragment),
|
||||
internal val localOnlyHotspotManager by lazy { LocalOnlyHotspotManager(this@TetheringFragment) }
|
||||
internal val bluetoothManager by lazy {
|
||||
requireContext().getSystemService<BluetoothManager>()?.adapter?.let {
|
||||
TetherManager.Bluetooth(this@TetheringFragment, it)
|
||||
}
|
||||
}
|
||||
private val tetherManagers by lazy {
|
||||
listOfNotNull(
|
||||
TetherManager.Wifi(this@TetheringFragment),
|
||||
TetherManager.Usb(this@TetheringFragment),
|
||||
bluetoothManager)
|
||||
bluetoothManager,
|
||||
)
|
||||
}
|
||||
@get:RequiresApi(30)
|
||||
private val tetherManagers30 by lazy @TargetApi(30) {
|
||||
listOf(TetherManager.Ethernet(this@TetheringFragment),
|
||||
TetherManager.Ncm(this@TetheringFragment),
|
||||
TetherManager.WiGig(this@TetheringFragment))
|
||||
}
|
||||
private val wifiManagerLegacy by lazy { TetherManager.WifiLegacy(this@TetheringFragment) }
|
||||
private val ethernetManager by lazy @TargetApi(30) { TetherManager.Ethernet(this@TetheringFragment) }
|
||||
|
||||
private var enabledIfaces = emptyList<String>()
|
||||
var activeIfaces = emptyList<String>()
|
||||
var localOnlyIfaces = emptyList<String>()
|
||||
var erroredIfaces = emptyList<String>()
|
||||
private var listDeferred = CompletableDeferred<List<Manager>>(emptyList())
|
||||
private fun updateEnabledTypes() {
|
||||
this@TetheringFragment.enabledTypes = enabledIfaces.map { TetherType.ofInterface(it) }.toSet()
|
||||
fun updateEnabledTypes() {
|
||||
this@TetheringFragment.enabledTypes =
|
||||
(activeIfaces + localOnlyIfaces).map { TetherType.ofInterface(it) }.toSet()
|
||||
}
|
||||
|
||||
val lastErrors = mutableMapOf<String, Int>()
|
||||
@@ -74,50 +81,42 @@ class TetheringFragment : Fragment(), ServiceConnection, Toolbar.OnMenuItemClick
|
||||
if (error == 0) lastErrors.remove(ifName) else lastErrors[ifName] = error
|
||||
}
|
||||
|
||||
suspend fun notifyInterfaceChanged(lastList: List<Manager>? = null) {
|
||||
@Suppress("NAME_SHADOWING") val lastList = lastList ?: listDeferred.await()
|
||||
val first = lastList.indexOfFirst { it is InterfaceManager }
|
||||
if (first >= 0) notifyItemRangeChanged(first, lastList.indexOfLast { it is InterfaceManager } - first + 1)
|
||||
}
|
||||
suspend fun notifyTetherTypeChanged() {
|
||||
updateEnabledTypes()
|
||||
val lastList = listDeferred.await()
|
||||
notifyInterfaceChanged(lastList)
|
||||
val first = lastList.indexOfLast { it !is TetherManager } + 1
|
||||
var first = lastList.indexOfFirst { it is InterfaceManager }
|
||||
withStarted {
|
||||
if (first >= 0) {
|
||||
notifyItemRangeChanged(first, lastList.indexOfLast { it is InterfaceManager } - first + 1)
|
||||
}
|
||||
first = lastList.indexOfLast { it !is TetherManager } + 1
|
||||
notifyItemRangeChanged(first, lastList.size - first)
|
||||
}
|
||||
}
|
||||
|
||||
fun update(activeIfaces: List<String>, localOnlyIfaces: List<String>, erroredIfaces: List<String>) {
|
||||
fun update() {
|
||||
val deferred = CompletableDeferred<List<Manager>>()
|
||||
listDeferred = deferred
|
||||
ifaceLookup = try {
|
||||
NetworkInterface.getNetworkInterfaces().asSequence().associateBy { it.name }
|
||||
} catch (e: SocketException) {
|
||||
Timber.d(e)
|
||||
} catch (e: Exception) {
|
||||
if (e is SocketException) Timber.d(e) else Timber.w(e)
|
||||
emptyMap()
|
||||
}
|
||||
enabledIfaces = activeIfaces + localOnlyIfaces
|
||||
updateEnabledTypes()
|
||||
|
||||
val list = ArrayList<Manager>()
|
||||
if (Services.p2p != null) list.add(repeaterManager)
|
||||
if (Build.VERSION.SDK_INT >= 26) list.add(localOnlyHotspotManager)
|
||||
list.add(localOnlyHotspotManager)
|
||||
val monitoredIfaces = binder?.monitoredIfaces ?: emptyList()
|
||||
updateMonitorList(activeIfaces - monitoredIfaces)
|
||||
updateMonitorList(activeIfaces - monitoredIfaces.toSet())
|
||||
list.addAll((activeIfaces + monitoredIfaces).toSortedSet()
|
||||
.map { InterfaceManager(this@TetheringFragment, it) })
|
||||
list.add(ManageBar)
|
||||
if (Build.VERSION.SDK_INT >= 24) {
|
||||
list.addAll(tetherManagers)
|
||||
tetherManagers.forEach { it.updateErrorMessage(erroredIfaces, lastErrors) }
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= 30) {
|
||||
list.addAll(tetherManagers30)
|
||||
tetherManagers30.forEach { it.updateErrorMessage(erroredIfaces, lastErrors) }
|
||||
}
|
||||
if (Build.VERSION.SDK_INT < 26) {
|
||||
list.add(wifiManagerLegacy)
|
||||
wifiManagerLegacy.onTetheringStarted()
|
||||
list.add(ethernetManager)
|
||||
ethernetManager.updateErrorMessage(erroredIfaces, lastErrors)
|
||||
}
|
||||
submitList(list) { deferred.complete(list) }
|
||||
}
|
||||
@@ -130,15 +129,17 @@ class TetheringFragment : Fragment(), ServiceConnection, Toolbar.OnMenuItemClick
|
||||
|
||||
@RequiresApi(29)
|
||||
val startRepeater = registerForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
|
||||
if (granted) requireActivity().startForegroundService(Intent(activity, RepeaterService::class.java))
|
||||
if (granted) app.startServiceWithLocation<RepeaterService>(requireContext()) else {
|
||||
Snackbar.make((activity as MainActivity).binding.fragmentHolder,
|
||||
R.string.repeater_missing_location_permissions, Snackbar.LENGTH_LONG).show()
|
||||
}
|
||||
}
|
||||
@RequiresApi(26)
|
||||
val startLocalOnlyHotspot = registerForActivityResult(ActivityResultContracts.RequestPermission()) {
|
||||
adapter.localOnlyHotspotManager.start(requireContext())
|
||||
}
|
||||
@RequiresApi(31)
|
||||
val requestBluetooth = registerForActivityResult(ActivityResultContracts.RequestPermission()) { granted ->
|
||||
if (granted) adapter.bluetoothManager.ensureInit(requireContext())
|
||||
if (granted) adapter.bluetoothManager!!.ensureInit(requireContext())
|
||||
}
|
||||
|
||||
var ifaceLookup: Map<String, NetworkInterface> = emptyMap()
|
||||
@@ -147,19 +148,22 @@ class TetheringFragment : Fragment(), ServiceConnection, Toolbar.OnMenuItemClick
|
||||
var binder: TetheringService.Binder? = null
|
||||
private val adapter = ManagerAdapter()
|
||||
private val receiver = broadcastReceiver { _, intent ->
|
||||
adapter.update(intent.tetheredIfaces ?: return@broadcastReceiver,
|
||||
intent.localOnlyTetheredIfaces ?: return@broadcastReceiver,
|
||||
intent.getStringArrayListExtra(TetheringManager.EXTRA_ERRORED_TETHER) ?: return@broadcastReceiver)
|
||||
adapter.activeIfaces = intent.tetheredIfaces ?: return@broadcastReceiver
|
||||
adapter.localOnlyIfaces = intent.localOnlyTetheredIfaces ?: return@broadcastReceiver
|
||||
adapter.erroredIfaces = intent.getStringArrayListExtra(TetheringManager.EXTRA_ERRORED_TETHER)
|
||||
?: return@broadcastReceiver
|
||||
adapter.updateEnabledTypes()
|
||||
adapter.update()
|
||||
}
|
||||
|
||||
private fun updateMonitorList(canMonitor: List<String> = emptyList()) {
|
||||
val activity = activity as? MainActivity
|
||||
val item = activity?.binding?.toolbar?.menu?.findItem(R.id.monitor) ?: return // assuming no longer foreground
|
||||
item.isNotGone = canMonitor.isNotEmpty()
|
||||
item.subMenu.apply {
|
||||
item.subMenu!!.apply {
|
||||
clear()
|
||||
for (iface in canMonitor.sorted()) add(iface).setOnMenuItemClickListener {
|
||||
ContextCompat.startForegroundService(activity, Intent(activity, TetheringService::class.java)
|
||||
activity.startForegroundService(Intent(activity, TetheringService::class.java)
|
||||
.putExtra(TetheringService.EXTRA_ADD_INTERFACE_MONITOR, iface))
|
||||
true
|
||||
}
|
||||
@@ -169,7 +173,7 @@ class TetheringFragment : Fragment(), ServiceConnection, Toolbar.OnMenuItemClick
|
||||
private var apConfigurationRunning = false
|
||||
override fun onMenuItemClick(item: MenuItem?): Boolean {
|
||||
return when (item?.itemId) {
|
||||
R.id.configuration -> item.subMenu.run {
|
||||
R.id.configuration -> item.subMenu!!.run {
|
||||
findItem(R.id.configuration_repeater).isNotGone = Services.p2p != null
|
||||
findItem(R.id.configuration_temp_hotspot).isNotGone =
|
||||
adapter.localOnlyHotspotManager.binder?.configuration != null
|
||||
@@ -189,28 +193,34 @@ class TetheringFragment : Fragment(), ServiceConnection, Toolbar.OnMenuItemClick
|
||||
}
|
||||
R.id.configuration_ap -> if (apConfigurationRunning) false else {
|
||||
apConfigurationRunning = true
|
||||
viewLifecycleOwner.lifecycleScope.launchWhenCreated {
|
||||
try {
|
||||
WifiApManager.configurationCompat
|
||||
viewLifecycleOwner.lifecycleScope.launch {
|
||||
val configuration = try {
|
||||
if (Build.VERSION.SDK_INT < 30) @Suppress("DEPRECATION") {
|
||||
WifiApManager.configurationLegacy?.toCompat() ?: SoftApConfigurationCompat()
|
||||
} else WifiApManager.configuration.toCompat()
|
||||
} catch (e: InvocationTargetException) {
|
||||
if (e.targetException !is SecurityException) Timber.w(e)
|
||||
try {
|
||||
RootManager.use { it.execute(WifiApCommands.GetConfiguration()) }
|
||||
if (Build.VERSION.SDK_INT < 30) @Suppress("DEPRECATION") {
|
||||
RootManager.use { it.execute(WifiApCommands.GetConfigurationLegacy()) }?.toCompat()
|
||||
?: SoftApConfigurationCompat()
|
||||
} else RootManager.use { it.execute(WifiApCommands.GetConfiguration()) }.toCompat()
|
||||
} catch (_: CancellationException) {
|
||||
null
|
||||
return@launch
|
||||
} catch (eRoot: Exception) {
|
||||
eRoot.addSuppressed(e)
|
||||
if (Build.VERSION.SDK_INT !in 26..29 || eRoot.getRootCause() !is SecurityException) {
|
||||
if (Build.VERSION.SDK_INT >= 29 || eRoot.getRootCause() !is SecurityException) {
|
||||
Timber.w(eRoot)
|
||||
}
|
||||
SmartSnackbar.make(eRoot).show()
|
||||
null
|
||||
return@launch
|
||||
}
|
||||
} catch (e: IllegalArgumentException) {
|
||||
Timber.w(e)
|
||||
SmartSnackbar.make(e).show()
|
||||
null
|
||||
}?.let { configuration ->
|
||||
return@launch
|
||||
}
|
||||
withStarted {
|
||||
WifiApDialogFragment().apply {
|
||||
arg(WifiApDialogFragment.Arg(configuration))
|
||||
key()
|
||||
@@ -226,10 +236,10 @@ class TetheringFragment : Fragment(), ServiceConnection, Toolbar.OnMenuItemClick
|
||||
|
||||
override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View {
|
||||
AlertDialogFragment.setResultListener<WifiApDialogFragment, WifiApDialogFragment.Arg>(this) { which, ret ->
|
||||
if (which == DialogInterface.BUTTON_POSITIVE) viewLifecycleOwner.lifecycleScope.launchWhenCreated {
|
||||
if (which == DialogInterface.BUTTON_POSITIVE) GlobalScope.launch {
|
||||
val configuration = ret!!.configuration
|
||||
@Suppress("DEPRECATION")
|
||||
if (Build.VERSION.SDK_INT in 28 until 30 &&
|
||||
if (Build.VERSION.SDK_INT < 30 &&
|
||||
configuration.isAutoShutdownEnabled != TetherTimeoutMonitor.enabled) try {
|
||||
TetherTimeoutMonitor.setEnabled(configuration.isAutoShutdownEnabled)
|
||||
} catch (e: Exception) {
|
||||
@@ -237,10 +247,18 @@ class TetheringFragment : Fragment(), ServiceConnection, Toolbar.OnMenuItemClick
|
||||
SmartSnackbar.make(e).show()
|
||||
}
|
||||
val success = try {
|
||||
WifiApManager.setConfigurationCompat(configuration)
|
||||
if (Build.VERSION.SDK_INT < 30) @Suppress("DEPRECATION") {
|
||||
WifiApManager.setConfiguration(configuration.toWifiConfiguration())
|
||||
} else WifiApManager.setConfiguration(configuration.toPlatform())
|
||||
} catch (e: InvocationTargetException) {
|
||||
try {
|
||||
RootManager.use { it.execute(WifiApCommands.SetConfiguration(configuration)) }
|
||||
if (Build.VERSION.SDK_INT < 30) @Suppress("DEPRECATION") {
|
||||
val wc = configuration.toWifiConfiguration()
|
||||
RootManager.use { it.execute(WifiApCommands.SetConfigurationLegacy(wc)) }
|
||||
} else {
|
||||
val platform = configuration.toPlatform()
|
||||
RootManager.use { it.execute(WifiApCommands.SetConfiguration(platform)) }
|
||||
}
|
||||
} catch (_: CancellationException) {
|
||||
} catch (eRoot: Exception) {
|
||||
eRoot.addSuppressed(e)
|
||||
@@ -256,7 +274,7 @@ class TetheringFragment : Fragment(), ServiceConnection, Toolbar.OnMenuItemClick
|
||||
binding.interfaces.layoutManager = LinearLayoutManager(context, RecyclerView.VERTICAL, false)
|
||||
binding.interfaces.itemAnimator = DefaultItemAnimator()
|
||||
binding.interfaces.adapter = adapter
|
||||
adapter.update(emptyList(), emptyList(), emptyList())
|
||||
adapter.update()
|
||||
ServiceForegroundConnector(this, this, TetheringService::class)
|
||||
(activity as MainActivity).binding.toolbar.apply {
|
||||
inflateMenu(R.menu.toolbar_tethering)
|
||||
@@ -275,18 +293,22 @@ class TetheringFragment : Fragment(), ServiceConnection, Toolbar.OnMenuItemClick
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
if (Build.VERSION.SDK_INT >= 27) ManageBar.Data.notifyChange()
|
||||
ManageBar.Data.notifyChange()
|
||||
}
|
||||
|
||||
override fun onServiceConnected(name: ComponentName?, service: IBinder?) {
|
||||
binder = service as TetheringService.Binder
|
||||
service.routingsChanged[this] = {
|
||||
lifecycleScope.launchWhenStarted { adapter.notifyInterfaceChanged() }
|
||||
lifecycleScope.launch {
|
||||
withStarted { adapter.update() }
|
||||
}
|
||||
}
|
||||
requireContext().registerReceiver(receiver, IntentFilter(TetheringManager.ACTION_TETHER_STATE_CHANGED))
|
||||
if (Build.VERSION.SDK_INT >= 30) {
|
||||
TetheringManager.registerTetheringEventCallback(null, adapter)
|
||||
TetherType.listener[this] = { lifecycleScope.launchWhenStarted { adapter.notifyTetherTypeChanged() } }
|
||||
TetherType.listener[this] = {
|
||||
lifecycleScope.launch { adapter.notifyTetherTypeChanged() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package be.mygod.vpnhotspot.manage
|
||||
|
||||
import android.bluetooth.BluetoothManager
|
||||
import android.content.ComponentName
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
@@ -10,13 +11,12 @@ import android.os.IBinder
|
||||
import android.service.quicksettings.Tile
|
||||
import android.widget.Toast
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.content.getSystemService
|
||||
import be.mygod.vpnhotspot.R
|
||||
import be.mygod.vpnhotspot.TetheringService
|
||||
import be.mygod.vpnhotspot.net.TetherType
|
||||
import be.mygod.vpnhotspot.net.TetheringManager
|
||||
import be.mygod.vpnhotspot.net.TetheringManager.tetheredIfaces
|
||||
import be.mygod.vpnhotspot.net.wifi.WifiApManager
|
||||
import be.mygod.vpnhotspot.util.broadcastReceiver
|
||||
import be.mygod.vpnhotspot.util.readableMessage
|
||||
import be.mygod.vpnhotspot.util.stopAndUnbind
|
||||
@@ -25,7 +25,6 @@ import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.launch
|
||||
import timber.log.Timber
|
||||
|
||||
@RequiresApi(24)
|
||||
sealed class TetheringTileService : IpNeighbourMonitoringTileService(), TetheringManager.StartTetheringCallback {
|
||||
protected val tileOff by lazy { Icon.createWithResource(application, icon) }
|
||||
protected val tileOn by lazy { Icon.createWithResource(application, R.drawable.ic_quick_settings_tile_on) }
|
||||
@@ -34,7 +33,7 @@ sealed class TetheringTileService : IpNeighbourMonitoringTileService(), Tetherin
|
||||
protected abstract val tetherType: TetherType
|
||||
protected open val icon get() = tetherType.icon
|
||||
private var tethered: List<String>? = null
|
||||
protected val interested get() = tethered?.filter { TetherType.ofInterface(it) == tetherType }
|
||||
protected val interested get() = tethered?.filter { TetherType.ofInterface(it).isA(tetherType) }
|
||||
protected var binder: TetheringService.Binder? = null
|
||||
|
||||
private val receiver = broadcastReceiver { _, intent ->
|
||||
@@ -108,7 +107,7 @@ sealed class TetheringTileService : IpNeighbourMonitoringTileService(), Tetherin
|
||||
stop()
|
||||
} catch (e: Exception) {
|
||||
onException(e)
|
||||
} else ContextCompat.startForegroundService(this, Intent(this, TetheringService::class.java)
|
||||
} else startForegroundService(Intent(this, TetheringService::class.java)
|
||||
.putExtra(TetheringService.EXTRA_ADD_INTERFACES, inactive.toTypedArray()))
|
||||
}
|
||||
}
|
||||
@@ -151,15 +150,16 @@ sealed class TetheringTileService : IpNeighbourMonitoringTileService(), Tetherin
|
||||
override val labelString get() = R.string.tethering_manage_bluetooth
|
||||
override val tetherType get() = TetherType.BLUETOOTH
|
||||
|
||||
override fun start() = BluetoothTethering.start(this)
|
||||
override fun start() = tethering!!.start(this, this)
|
||||
override fun stop() {
|
||||
TetheringManager.stopTethering(TetheringManager.TETHERING_BLUETOOTH, this::onException)
|
||||
Thread.sleep(1) // give others a room to breathe
|
||||
tethering!!.stop(this::onException)
|
||||
onTetheringStarted() // force flush state
|
||||
}
|
||||
|
||||
override fun onStartListening() {
|
||||
tethering = BluetoothTethering(this) { updateTile() }
|
||||
tethering = getSystemService<BluetoothManager>()?.adapter?.let {
|
||||
BluetoothTethering(this, it) { updateTile() }
|
||||
}
|
||||
super.onStartListening()
|
||||
}
|
||||
override fun onStopListening() {
|
||||
@@ -187,7 +187,7 @@ sealed class TetheringTileService : IpNeighbourMonitoringTileService(), Tetherin
|
||||
icon = tileOff
|
||||
}
|
||||
null -> {
|
||||
state = Tile.STATE_UNAVAILABLE
|
||||
state = Tile.STATE_INACTIVE
|
||||
icon = tileOff
|
||||
subtitle(tethering?.activeFailureCause?.readableMessage)
|
||||
}
|
||||
@@ -198,7 +198,8 @@ sealed class TetheringTileService : IpNeighbourMonitoringTileService(), Tetherin
|
||||
}
|
||||
|
||||
override fun onClick() {
|
||||
when (tethering?.active) {
|
||||
val tethering = tethering
|
||||
if (tethering == null) tapPending = true else when (tethering.active) {
|
||||
true -> {
|
||||
val binder = binder
|
||||
if (binder == null) tapPending = true else {
|
||||
@@ -207,12 +208,12 @@ sealed class TetheringTileService : IpNeighbourMonitoringTileService(), Tetherin
|
||||
stop()
|
||||
} catch (e: Exception) {
|
||||
onException(e)
|
||||
} else ContextCompat.startForegroundService(this, Intent(this, TetheringService::class.java)
|
||||
} else startForegroundService(Intent(this, TetheringService::class.java)
|
||||
.putExtra(TetheringService.EXTRA_ADD_INTERFACES, inactive.toTypedArray()))
|
||||
}
|
||||
}
|
||||
false -> start()
|
||||
else -> tapPending = true
|
||||
else -> ManageBar.start(this)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -224,39 +225,4 @@ sealed class TetheringTileService : IpNeighbourMonitoringTileService(), Tetherin
|
||||
override fun start() = TetheringManager.startTethering(TetheringManager.TETHERING_ETHERNET, true, this)
|
||||
override fun stop() = TetheringManager.stopTethering(TetheringManager.TETHERING_ETHERNET, this::onException)
|
||||
}
|
||||
@RequiresApi(30)
|
||||
class Ncm : TetheringTileService() {
|
||||
override val labelString get() = R.string.tethering_manage_ncm
|
||||
override val tetherType get() = TetherType.NCM
|
||||
|
||||
override fun start() = TetheringManager.startTethering(TetheringManager.TETHERING_NCM, true, this)
|
||||
override fun stop() = TetheringManager.stopTethering(TetheringManager.TETHERING_NCM, this::onException)
|
||||
}
|
||||
@RequiresApi(30)
|
||||
class WiGig : TetheringTileService() {
|
||||
override val labelString get() = R.string.tethering_manage_wigig
|
||||
override val tetherType get() = TetherType.WIGIG
|
||||
|
||||
override fun start() = TetheringManager.startTethering(TetheringManager.TETHERING_WIGIG, true, this)
|
||||
override fun stop() = TetheringManager.stopTethering(TetheringManager.TETHERING_WIGIG, this::onException)
|
||||
}
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
@Deprecated("Not usable since API 25")
|
||||
class WifiLegacy : TetheringTileService() {
|
||||
override val labelString get() = R.string.tethering_manage_wifi_legacy
|
||||
override val tetherType get() = TetherType.WIFI
|
||||
override val icon get() = R.drawable.ic_device_wifi_tethering
|
||||
|
||||
override fun start() = try {
|
||||
WifiApManager.start()
|
||||
} catch (e: Exception) {
|
||||
onException(e)
|
||||
}
|
||||
override fun stop() = try {
|
||||
WifiApManager.stop()
|
||||
} catch (e: Exception) {
|
||||
onException(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package be.mygod.vpnhotspot.net
|
||||
|
||||
import android.net.MacAddress
|
||||
import android.os.Build
|
||||
import android.system.ErrnoException
|
||||
import android.system.Os
|
||||
@@ -15,7 +16,7 @@ import java.io.IOException
|
||||
import java.net.Inet4Address
|
||||
import java.net.InetAddress
|
||||
|
||||
data class IpNeighbour(val ip: InetAddress, val dev: String, val lladdr: MacAddressCompat, val state: State) {
|
||||
data class IpNeighbour(val ip: InetAddress, val dev: String, val lladdr: MacAddress, val state: State) {
|
||||
enum class State {
|
||||
INCOMPLETE, VALID, FAILED, DELETING
|
||||
}
|
||||
@@ -27,8 +28,8 @@ data class IpNeighbour(val ip: InetAddress, val dev: String, val lladdr: MacAddr
|
||||
* https://people.cs.clemson.edu/~westall/853/notes/arpstate.pdf
|
||||
* Assumptions: IP addr (key) always present and RTM_GETNEIGH is never used
|
||||
*/
|
||||
private val parser = "^(Deleted )?([^ ]+) dev ([^ ]+) (lladdr ([^ ]*))?.*?( ([INCOMPLET,RAHBSDYF]+))?\$"
|
||||
.toRegex()
|
||||
private val parser = ("^(Deleted )?(?:([^ ]+) )?dev ([^ ]+) (?:lladdr ([^ ]*))?.*?" +
|
||||
"(?: ([INCOMPLET,RAHBSDYF]+))?\$").toRegex()
|
||||
/**
|
||||
* Fallback format will be used if if_indextoname returns null, which some stupid devices do.
|
||||
*
|
||||
@@ -49,14 +50,15 @@ data class IpNeighbour(val ip: InetAddress, val dev: String, val lladdr: MacAddr
|
||||
suspend fun parse(line: String, fullMode: Boolean): List<IpNeighbour> {
|
||||
return if (line.isBlank()) emptyList() else try {
|
||||
val match = parser.matchEntire(line)!!
|
||||
val ip = parseNumericAddress(match.groupValues[2]) // by regex, ip is non-empty
|
||||
val devs = substituteDev(match.groupValues[3]) // by regex, dev is non-empty as well
|
||||
val state = if (match.groupValues[1].isNotEmpty()) State.DELETING else when (match.groupValues[7]) {
|
||||
if (match.groups[2] == null) return emptyList()
|
||||
val ip = parseNumericAddress(match.groupValues[2])
|
||||
val devs = substituteDev(match.groupValues[3]) // by regex, dev is non-empty
|
||||
val state = if (match.groupValues[1].isNotEmpty()) State.DELETING else when (match.groupValues[5]) {
|
||||
"", "INCOMPLETE" -> State.INCOMPLETE
|
||||
"REACHABLE", "DELAY", "STALE", "PROBE", "PERMANENT" -> State.VALID
|
||||
"FAILED" -> State.FAILED
|
||||
"NOARP" -> return emptyList() // skip
|
||||
else -> throw IllegalArgumentException("Unknown state encountered: ${match.groupValues[7]}")
|
||||
else -> throw IllegalArgumentException("Unknown state encountered: ${match.groupValues[5]}")
|
||||
}
|
||||
var lladdr = MacAddressCompat.ALL_ZEROS_ADDRESS
|
||||
if (!fullMode && state != State.VALID) {
|
||||
@@ -64,7 +66,7 @@ data class IpNeighbour(val ip: InetAddress, val dev: String, val lladdr: MacAddr
|
||||
return devs.map { IpNeighbour(ip, it, lladdr, State.DELETING) }
|
||||
}
|
||||
if (match.groups[4] != null) try {
|
||||
lladdr = MacAddressCompat.fromString(match.groupValues[5])
|
||||
lladdr = MacAddress.fromString(match.groupValues[4])
|
||||
} catch (e: IllegalArgumentException) {
|
||||
if (state != State.INCOMPLETE && state != State.DELETING) {
|
||||
Timber.w(IOException("Failed to find MAC address for $line", e))
|
||||
@@ -78,7 +80,7 @@ data class IpNeighbour(val ip: InetAddress, val dev: String, val lladdr: MacAddr
|
||||
val list = arp()
|
||||
.asSequence()
|
||||
.filter { parseNumericAddress(it[ARP_IP_ADDRESS]) == ip && it[ARP_DEVICE] in devs }
|
||||
.map { MacAddressCompat.fromString(it[ARP_HW_ADDRESS]) }
|
||||
.map { MacAddress.fromString(it[ARP_HW_ADDRESS]) }
|
||||
.filter { it != MacAddressCompat.ALL_ZEROS_ADDRESS }
|
||||
.distinct()
|
||||
.toList()
|
||||
@@ -137,5 +139,4 @@ data class IpNeighbour(val ip: InetAddress, val dev: String, val lladdr: MacAddr
|
||||
data class IpDev(val ip: InetAddress, val dev: String) {
|
||||
override fun toString() = "$ip%$dev"
|
||||
}
|
||||
@Suppress("FunctionName")
|
||||
fun IpDev(neighbour: IpNeighbour) = IpDev(neighbour.ip, neighbour.dev)
|
||||
|
||||
@@ -1,97 +1,34 @@
|
||||
package be.mygod.vpnhotspot.net
|
||||
|
||||
import android.net.MacAddress
|
||||
import androidx.annotation.RequiresApi
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.ByteOrder
|
||||
|
||||
/**
|
||||
* Compat support class for [MacAddress].
|
||||
* This used to be a compat support class for [MacAddress].
|
||||
* Now it is just a convenient class for backwards compatibility.
|
||||
*/
|
||||
@JvmInline
|
||||
value class MacAddressCompat(val addr: Long) {
|
||||
companion object {
|
||||
private const val ETHER_ADDR_LEN = 6
|
||||
/**
|
||||
* The MacAddress zero MAC address.
|
||||
*
|
||||
* Not publicly exposed or treated specially since the OUI 00:00:00 is registered.
|
||||
* @hide
|
||||
*/
|
||||
val ALL_ZEROS_ADDRESS = MacAddressCompat(0)
|
||||
val ANY_ADDRESS = MacAddressCompat(2)
|
||||
val ALL_ZEROS_ADDRESS = MacAddress.fromBytes(byteArrayOf(0, 0, 0, 0, 0, 0))
|
||||
val ANY_ADDRESS = MacAddress.fromBytes(byteArrayOf(2, 0, 0, 0, 0, 0))
|
||||
|
||||
/**
|
||||
* Creates a MacAddress from the given byte array representation.
|
||||
* A valid byte array representation for a MacAddress is a non-null array of length 6.
|
||||
*
|
||||
* @param addr a byte array representation of a MAC address.
|
||||
* @return the MacAddress corresponding to the given byte array representation.
|
||||
* @throws IllegalArgumentException if the given byte array is not a valid representation.
|
||||
*/
|
||||
fun fromBytes(addr: ByteArray) = ByteBuffer.allocate(Long.SIZE_BYTES).run {
|
||||
fun MacAddress.toLong() = ByteBuffer.allocate(Long.SIZE_BYTES).apply {
|
||||
order(ByteOrder.LITTLE_ENDIAN)
|
||||
put(when (addr.size) {
|
||||
ETHER_ADDR_LEN -> addr
|
||||
8 -> {
|
||||
require(addr.take(2).all { it == 0.toByte() }) {
|
||||
"Unrecognized padding " + addr.joinToString(":") { "%02x".format(it) }
|
||||
}
|
||||
addr.drop(2).toByteArray()
|
||||
}
|
||||
else -> throw IllegalArgumentException(addr.joinToString(":") { "%02x".format(it) } +
|
||||
" was not a valid MAC address")
|
||||
})
|
||||
put(toByteArray())
|
||||
rewind()
|
||||
MacAddressCompat(long)
|
||||
}
|
||||
/**
|
||||
* Creates a MacAddress from the given String representation. A valid String representation
|
||||
* for a MacAddress is a series of 6 values in the range [0,ff] printed in hexadecimal
|
||||
* and joined by ':' characters.
|
||||
*
|
||||
* @param addr a String representation of a MAC address.
|
||||
* @return the MacAddress corresponding to the given String representation.
|
||||
* @throws IllegalArgumentException if the given String is not a valid representation.
|
||||
*/
|
||||
fun fromString(addr: String) = ByteBuffer.allocate(Long.SIZE_BYTES).run {
|
||||
order(ByteOrder.LITTLE_ENDIAN)
|
||||
var start = 0
|
||||
var i = 0
|
||||
while (position() < ETHER_ADDR_LEN && start < addr.length) {
|
||||
val end = i
|
||||
if (addr.getOrElse(i) { ':' } == ':') ++i else if (i < start + 2) {
|
||||
++i
|
||||
continue
|
||||
}
|
||||
put(if (start == end) 0 else try {
|
||||
Integer.parseInt(addr.substring(start, end), 16).toByte()
|
||||
} catch (e: NumberFormatException) {
|
||||
throw IllegalArgumentException(e)
|
||||
})
|
||||
start = i
|
||||
}
|
||||
require(position() == ETHER_ADDR_LEN) { "MAC address too short" }
|
||||
rewind()
|
||||
MacAddressCompat(long)
|
||||
}.long
|
||||
}
|
||||
|
||||
@RequiresApi(28)
|
||||
fun MacAddress.toCompat() = fromBytes(toByteArray())
|
||||
}
|
||||
|
||||
fun validate() = require(addr and ((1L shl 48) - 1).inv() == 0L)
|
||||
|
||||
fun toList() = ByteBuffer.allocate(8).run {
|
||||
fun toPlatform() = MacAddress.fromBytes(ByteBuffer.allocate(8).run {
|
||||
order(ByteOrder.LITTLE_ENDIAN)
|
||||
putLong(addr)
|
||||
array().take(6)
|
||||
}
|
||||
|
||||
@RequiresApi(28)
|
||||
fun toPlatform() = MacAddress.fromBytes(toList().toByteArray())
|
||||
|
||||
override fun toString() = toList().joinToString(":") { "%02x".format(it) }
|
||||
|
||||
fun toOui() = toList().joinToString("") { "%02x".format(it) }.substring(0, 9)
|
||||
}.toByteArray())
|
||||
}
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
package be.mygod.vpnhotspot.net
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.annotation.TargetApi
|
||||
import android.net.LinkProperties
|
||||
import android.net.MacAddress
|
||||
import android.net.RouteInfo
|
||||
import android.os.Build
|
||||
import androidx.annotation.RequiresApi
|
||||
import android.system.Os
|
||||
import be.mygod.vpnhotspot.App.Companion.app
|
||||
import be.mygod.vpnhotspot.R
|
||||
import be.mygod.vpnhotspot.net.monitor.FallbackUpstreamMonitor
|
||||
@@ -15,7 +13,9 @@ import be.mygod.vpnhotspot.net.monitor.UpstreamMonitor
|
||||
import be.mygod.vpnhotspot.room.AppDatabase
|
||||
import be.mygod.vpnhotspot.root.RootManager
|
||||
import be.mygod.vpnhotspot.root.RoutingCommands
|
||||
import be.mygod.vpnhotspot.util.*
|
||||
import be.mygod.vpnhotspot.util.RootSession
|
||||
import be.mygod.vpnhotspot.util.allInterfaceNames
|
||||
import be.mygod.vpnhotspot.util.allRoutes
|
||||
import be.mygod.vpnhotspot.widget.SmartSnackbar
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import timber.log.Timber
|
||||
@@ -125,7 +125,6 @@ class Routing(private val caller: Any, private val downstream: String) : IpNeigh
|
||||
*
|
||||
* Source: https://android.googlesource.com/platform/system/netd/+/3b47c793ff7ade843b1d85a9be8461c3b4dc693e
|
||||
*/
|
||||
@RequiresApi(28)
|
||||
Netd,
|
||||
}
|
||||
|
||||
@@ -151,35 +150,24 @@ class Routing(private val caller: Any, private val downstream: String) : IpNeigh
|
||||
private val upstreams = HashSet<String>()
|
||||
private class InterfaceGoneException(upstream: String) : IOException("Interface $upstream not found")
|
||||
private open inner class Upstream(val priority: Int) : UpstreamMonitor.Callback {
|
||||
/**
|
||||
* The only case when upstream is null is on API 23- and we are using system default rules.
|
||||
*/
|
||||
inner class Subrouting(priority: Int, val upstream: String) {
|
||||
val ifindex = if (upstream.isEmpty()) 0 else if_nametoindex(upstream).also {
|
||||
val ifindex = Os.if_nametoindex(upstream).also {
|
||||
if (it <= 0) throw InterfaceGoneException(upstream)
|
||||
}
|
||||
val transaction = RootSession.beginTransaction().safeguard {
|
||||
if (upstream.isEmpty()) {
|
||||
ipRule("goto $RULE_PRIORITY_TETHERING", priority) // skip unreachable rule
|
||||
} else ipRuleLookup(ifindex, priority)
|
||||
@TargetApi(28) when (masqueradeMode) {
|
||||
ipRuleLookup(ifindex, priority)
|
||||
when (masqueradeMode) {
|
||||
MasqueradeMode.None -> { } // nothing to be done here
|
||||
MasqueradeMode.Simple -> {
|
||||
// note: specifying -i wouldn't work for POSTROUTING
|
||||
iptablesAdd(if (upstream.isEmpty()) {
|
||||
"vpnhotspot_masquerade -s $hostSubnet -j MASQUERADE"
|
||||
} else "vpnhotspot_masquerade -s $hostSubnet -o $upstream -j MASQUERADE", "nat")
|
||||
}
|
||||
MasqueradeMode.Netd -> {
|
||||
check(upstream.isNotEmpty()) // fallback is only needed for repeater on API 23 < 28
|
||||
MasqueradeMode.Simple -> iptablesAdd(
|
||||
"vpnhotspot_masquerade -s $hostSubnet -o $upstream -j MASQUERADE", "nat")
|
||||
/**
|
||||
* 0 means that there are no interface addresses coming after, which is unused anyway.
|
||||
*
|
||||
* https://android.googlesource.com/platform/frameworks/base/+/android-5.0.0_r1/services/core/java/com/android/server/NetworkManagementService.java#1251
|
||||
* https://android.googlesource.com/platform/system/netd/+/android-5.0.0_r1/server/CommandListener.cpp#638
|
||||
*/
|
||||
ndc("Nat", "ndc nat enable $downstream $upstream 0")
|
||||
}
|
||||
MasqueradeMode.Netd -> ndc("Nat", "ndc nat enable $downstream $upstream 0")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -225,16 +213,10 @@ class Routing(private val caller: Any, private val downstream: String) : IpNeigh
|
||||
updateDnsRoute()
|
||||
}
|
||||
}
|
||||
private val fallbackUpstream = object : Upstream(RULE_PRIORITY_UPSTREAM_FALLBACK) {
|
||||
@SuppressLint("NewApi")
|
||||
override fun onFallback() = onAvailable(LinkProperties().apply {
|
||||
interfaceName = ""
|
||||
setDnsServers(listOf(parseNumericAddress("8.8.8.8")))
|
||||
})
|
||||
}
|
||||
private val fallbackUpstream = Upstream(RULE_PRIORITY_UPSTREAM_FALLBACK)
|
||||
private val upstream = Upstream(RULE_PRIORITY_UPSTREAM)
|
||||
|
||||
private inner class Client(private val ip: Inet4Address, mac: MacAddressCompat) : AutoCloseable {
|
||||
private inner class Client(private val ip: Inet4Address, mac: MacAddress) : AutoCloseable {
|
||||
private val transaction = RootSession.beginTransaction().safeguard {
|
||||
val address = ip.hostAddress
|
||||
iptablesInsert("vpnhotspot_acl -i $downstream -s $address -j ACCEPT")
|
||||
@@ -287,7 +269,7 @@ class Routing(private val caller: Any, private val downstream: String) : IpNeigh
|
||||
* but may be broken when system tethering shutdown before local-only interfaces.
|
||||
*/
|
||||
fun ipForward() {
|
||||
if (Build.VERSION.SDK_INT >= 23) try {
|
||||
try {
|
||||
transaction.ndc("ipfwd", "ndc ipfwd enable vpnhotspot_$downstream",
|
||||
"ndc ipfwd disable vpnhotspot_$downstream")
|
||||
return
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
package be.mygod.vpnhotspot.net
|
||||
|
||||
import android.os.Build
|
||||
import android.provider.Settings
|
||||
import be.mygod.vpnhotspot.App.Companion.app
|
||||
import be.mygod.vpnhotspot.root.SettingsGlobalPut
|
||||
import timber.log.Timber
|
||||
|
||||
/**
|
||||
* It's hard to change tethering rules with Tethering hardware acceleration enabled for now.
|
||||
@@ -15,19 +13,6 @@ import timber.log.Timber
|
||||
* https://android.googlesource.com/platform/hardware/qcom/data/ipacfg-mgr/+/master/msm8998/ipacm/src/IPACM_OffloadManager.cpp
|
||||
*/
|
||||
object TetherOffloadManager {
|
||||
val supported by lazy {
|
||||
Build.VERSION.SDK_INT >= 27 || try {
|
||||
Settings.Global::class.java.getDeclaredField("TETHER_OFFLOAD_DISABLED").get(null).let {
|
||||
require(it == TETHER_OFFLOAD_DISABLED) { "Unknown field $it" }
|
||||
}
|
||||
true
|
||||
} catch (_: NoSuchFieldException) {
|
||||
false
|
||||
} catch (e: Exception) {
|
||||
Timber.w(e)
|
||||
false
|
||||
}
|
||||
}
|
||||
private const val TETHER_OFFLOAD_DISABLED = "tether_offload_disabled"
|
||||
val enabled get() = Settings.Global.getInt(app.contentResolver, TETHER_OFFLOAD_DISABLED, 0) == 0
|
||||
suspend fun setEnabled(value: Boolean) = SettingsGlobalPut.int(TETHER_OFFLOAD_DISABLED, if (value) 0 else 1)
|
||||
|
||||
@@ -28,6 +28,8 @@ enum class TetherType(@DrawableRes val icon: Int) {
|
||||
else -> false
|
||||
}
|
||||
|
||||
fun isA(other: TetherType) = this == other || other == USB && this == NCM
|
||||
|
||||
companion object : TetheringManager.TetheringEventCallback {
|
||||
private lateinit var usbRegexs: List<Pattern>
|
||||
private lateinit var wifiRegexs: List<Pattern>
|
||||
@@ -58,6 +60,9 @@ enum class TetherType(@DrawableRes val icon: Int) {
|
||||
private fun updateRegexs() = synchronized(this) {
|
||||
if (!requiresUpdate) return@synchronized
|
||||
requiresUpdate = false
|
||||
usbRegexs = emptyList()
|
||||
wifiRegexs = emptyList()
|
||||
bluetoothRegexs = emptyList()
|
||||
TetheringManager.registerTetheringEventCallback(null, this)
|
||||
val info = TetheringManager.resolvedService.serviceInfo
|
||||
val tethering = "com.android.networkstack.tethering" to
|
||||
@@ -71,9 +76,9 @@ enum class TetherType(@DrawableRes val icon: Int) {
|
||||
}
|
||||
|
||||
@RequiresApi(30)
|
||||
override fun onTetherableInterfaceRegexpsChanged(args: Array<out Any?>?) = synchronized(this) {
|
||||
override fun onTetherableInterfaceRegexpsChanged(reg: Any?) = synchronized(this) {
|
||||
if (requiresUpdate) return@synchronized
|
||||
Timber.i("onTetherableInterfaceRegexpsChanged: ${args?.contentDeepToString()}")
|
||||
Timber.i("onTetherableInterfaceRegexpsChanged: $reg")
|
||||
TetheringManager.unregisterTetheringEventCallback(this)
|
||||
requiresUpdate = true
|
||||
listener()
|
||||
@@ -104,13 +109,20 @@ enum class TetherType(@DrawableRes val icon: Int) {
|
||||
*
|
||||
* Based on: https://android.googlesource.com/platform/frameworks/base/+/5d36f01/packages/Tethering/src/com/android/networkstack/tethering/Tethering.java#479
|
||||
*/
|
||||
fun ofInterface(iface: String?, p2pDev: String? = null) = synchronized(this) { ofInterfaceImpl(iface, p2pDev) }
|
||||
private tailrec fun ofInterfaceImpl(iface: String?, p2pDev: String?): TetherType = when {
|
||||
iface == null -> NONE
|
||||
iface == p2pDev -> WIFI_P2P
|
||||
fun ofInterface(iface: String?, p2pDev: String? = null) = when (iface) {
|
||||
null -> NONE
|
||||
p2pDev -> WIFI_P2P
|
||||
else -> try {
|
||||
synchronized(this) { ofInterfaceImpl(iface) }
|
||||
} catch (e: RuntimeException) {
|
||||
Timber.w(e)
|
||||
NONE
|
||||
}
|
||||
}
|
||||
private tailrec fun ofInterfaceImpl(iface: String): TetherType = when {
|
||||
requiresUpdate -> {
|
||||
if (Build.VERSION.SDK_INT >= 30) updateRegexs() else error("unexpected requiresUpdate")
|
||||
ofInterfaceImpl(iface, p2pDev)
|
||||
ofInterfaceImpl(iface)
|
||||
}
|
||||
wifiRegexs.any { it.matcher(iface).matches() } -> WIFI
|
||||
wigigRegexs.any { it.matcher(iface).matches() } -> WIGIG
|
||||
|
||||
@@ -10,6 +10,7 @@ import android.content.pm.PackageManager
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.Network
|
||||
import android.os.Build
|
||||
import android.os.DeadObjectException
|
||||
import android.os.Handler
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.core.os.ExecutorCompat
|
||||
@@ -66,7 +67,11 @@ object TetheringManager {
|
||||
}
|
||||
|
||||
private object InPlaceExecutor : Executor {
|
||||
override fun execute(command: Runnable) = command.run()
|
||||
override fun execute(command: Runnable) = try {
|
||||
command.run()
|
||||
} catch (e: Exception) {
|
||||
Timber.w(e) // prevent Binder stub swallowing the exception
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -89,9 +94,7 @@ object TetheringManager {
|
||||
* https://android.googlesource.com/platform/frameworks/base.git/+/2a091d7aa0c174986387e5d56bf97a87fe075bdb%5E%21/services/java/com/android/server/connectivity/Tethering.java
|
||||
*/
|
||||
const val ACTION_TETHER_STATE_CHANGED = "android.net.conn.TETHER_STATE_CHANGED"
|
||||
@RequiresApi(26)
|
||||
private const val EXTRA_ACTIVE_LOCAL_ONLY_LEGACY = "localOnlyArray"
|
||||
private const val EXTRA_ACTIVE_TETHER_LEGACY = "activeArray"
|
||||
/**
|
||||
* gives a String[] listing all the interfaces currently in local-only
|
||||
* mode (ie, has DHCPv4+IPv6-ULA support and no packet forwarding)
|
||||
@@ -102,7 +105,6 @@ object TetheringManager {
|
||||
* gives a String[] listing all the interfaces currently tethered
|
||||
* (ie, has DHCPv4 support and packets potentially forwarded/NATed)
|
||||
*/
|
||||
@RequiresApi(26)
|
||||
private const val EXTRA_ACTIVE_TETHER = "tetherArray"
|
||||
/**
|
||||
* gives a String[] listing all the interfaces we tried to tether and
|
||||
@@ -126,7 +128,6 @@ object TetheringManager {
|
||||
* Wifi tethering type.
|
||||
* @see [startTethering].
|
||||
*/
|
||||
@RequiresApi(24)
|
||||
const val TETHERING_WIFI = 0
|
||||
/**
|
||||
* USB tethering type.
|
||||
@@ -134,48 +135,33 @@ object TetheringManager {
|
||||
* Requires MANAGE_USB permission, unfortunately.
|
||||
*
|
||||
* Source: https://android.googlesource.com/platform/frameworks/base/+/7ca5d3a/services/usb/java/com/android/server/usb/UsbService.java#389
|
||||
* @see [startTethering].
|
||||
* @see startTethering
|
||||
*/
|
||||
@RequiresApi(24)
|
||||
const val TETHERING_USB = 1
|
||||
/**
|
||||
* Bluetooth tethering type.
|
||||
*
|
||||
* Requires BLUETOOTH permission.
|
||||
* @see [startTethering].
|
||||
* @see startTethering
|
||||
*/
|
||||
@RequiresApi(24)
|
||||
const val TETHERING_BLUETOOTH = 2
|
||||
/**
|
||||
* Ncm local tethering type.
|
||||
*
|
||||
* @see [startTethering]
|
||||
*/
|
||||
@RequiresApi(30)
|
||||
const val TETHERING_NCM = 4
|
||||
/**
|
||||
* Ethernet tethering type.
|
||||
*
|
||||
* Requires MANAGE_USB permission, also.
|
||||
* @see [startTethering]
|
||||
* @see startTethering
|
||||
*/
|
||||
@RequiresApi(30)
|
||||
const val TETHERING_ETHERNET = 5
|
||||
/**
|
||||
* WIGIG tethering type. Use a separate type to prevent
|
||||
* conflicts with TETHERING_WIFI
|
||||
* This type is only used internally by the tethering module
|
||||
* @hide
|
||||
*/
|
||||
@RequiresApi(30)
|
||||
const val TETHERING_WIGIG = 6
|
||||
@RequiresApi(31) // TETHERING_WIFI_P2P
|
||||
private val expectedTypes = setOf(TETHERING_WIFI, TETHERING_USB, TETHERING_BLUETOOTH, 3, TETHERING_ETHERNET)
|
||||
|
||||
@get:RequiresApi(30)
|
||||
private val clazz by lazy { Class.forName("android.net.TetheringManager") }
|
||||
@get:RequiresApi(30)
|
||||
private val instance by lazy @TargetApi(30) {
|
||||
@SuppressLint("WrongConstant") // hidden services are not included in constants as of R preview 4
|
||||
val service = Services.context.getSystemService(TETHERING_SERVICE)
|
||||
val service = Services.context.getSystemService(TETHERING_SERVICE)!!
|
||||
service
|
||||
}
|
||||
|
||||
@@ -188,20 +174,17 @@ object TetheringManager {
|
||||
}
|
||||
}.first()
|
||||
|
||||
@get:RequiresApi(24)
|
||||
private val classOnStartTetheringCallback by lazy {
|
||||
Class.forName("android.net.ConnectivityManager\$OnStartTetheringCallback")
|
||||
}
|
||||
@get:RequiresApi(24)
|
||||
private val startTetheringLegacy by lazy {
|
||||
ConnectivityManager::class.java.getDeclaredMethod("startTethering",
|
||||
Int::class.java, Boolean::class.java, classOnStartTetheringCallback, Handler::class.java)
|
||||
}
|
||||
@get:RequiresApi(24)
|
||||
private val stopTetheringLegacy by lazy {
|
||||
ConnectivityManager::class.java.getDeclaredMethod("stopTethering", Int::class.java)
|
||||
}
|
||||
private val getLastTetherError by lazy {
|
||||
private val getLastTetherError by lazy @SuppressLint("SoonBlockedPrivateApi") {
|
||||
ConnectivityManager::class.java.getDeclaredMethod("getLastTetherError", String::class.java)
|
||||
}
|
||||
|
||||
@@ -210,50 +193,50 @@ object TetheringManager {
|
||||
Class.forName("android.net.TetheringManager\$TetheringRequest\$Builder")
|
||||
}
|
||||
@get:RequiresApi(30)
|
||||
private val newTetheringRequestBuilder by lazy { classTetheringRequestBuilder.getConstructor(Int::class.java) }
|
||||
private val newTetheringRequestBuilder by lazy @TargetApi(30) {
|
||||
classTetheringRequestBuilder.getConstructor(Int::class.java)
|
||||
}
|
||||
// @get:RequiresApi(30)
|
||||
// private val setStaticIpv4Addresses by lazy {
|
||||
// classTetheringRequestBuilder.getDeclaredMethod("setStaticIpv4Addresses",
|
||||
// LinkAddress::class.java, LinkAddress::class.java)
|
||||
// }
|
||||
@get:RequiresApi(30)
|
||||
private val setExemptFromEntitlementCheck by lazy {
|
||||
private val setExemptFromEntitlementCheck by lazy @TargetApi(30) {
|
||||
classTetheringRequestBuilder.getDeclaredMethod("setExemptFromEntitlementCheck", Boolean::class.java)
|
||||
}
|
||||
@get:RequiresApi(30)
|
||||
private val setShouldShowEntitlementUi by lazy {
|
||||
private val setShouldShowEntitlementUi by lazy @TargetApi(30) {
|
||||
classTetheringRequestBuilder.getDeclaredMethod("setShouldShowEntitlementUi", Boolean::class.java)
|
||||
}
|
||||
@get:RequiresApi(30)
|
||||
private val build by lazy { classTetheringRequestBuilder.getDeclaredMethod("build") }
|
||||
private val build by lazy @TargetApi(30) { classTetheringRequestBuilder.getDeclaredMethod("build") }
|
||||
|
||||
@get:RequiresApi(30)
|
||||
private val interfaceStartTetheringCallback by lazy {
|
||||
Class.forName("android.net.TetheringManager\$StartTetheringCallback")
|
||||
}
|
||||
@get:RequiresApi(30)
|
||||
private val startTethering by lazy {
|
||||
private val startTethering by lazy @TargetApi(30) {
|
||||
clazz.getDeclaredMethod("startTethering", Class.forName("android.net.TetheringManager\$TetheringRequest"),
|
||||
Executor::class.java, interfaceStartTetheringCallback)
|
||||
}
|
||||
@get:RequiresApi(30)
|
||||
private val stopTethering by lazy { clazz.getDeclaredMethod("stopTethering", Int::class.java) }
|
||||
private val stopTethering by lazy @TargetApi(30) { clazz.getDeclaredMethod("stopTethering", Int::class.java) }
|
||||
|
||||
@Deprecated("Legacy API")
|
||||
@RequiresApi(24)
|
||||
fun startTetheringLegacy(type: Int, showProvisioningUi: Boolean, callback: StartTetheringCallback,
|
||||
handler: Handler? = null, cacheDir: File = app.deviceStorage.codeCacheDir) {
|
||||
val reference = WeakReference(callback)
|
||||
val proxy = ProxyBuilder.forClass(classOnStartTetheringCallback).apply {
|
||||
dexCache(cacheDir)
|
||||
handler { proxy, method, args ->
|
||||
if (args.isNotEmpty()) Timber.w("Unexpected args for ${method.name}: $args")
|
||||
@Suppress("NAME_SHADOWING") val callback = reference.get()
|
||||
when (method.name) {
|
||||
"onTetheringStarted" -> callback?.onTetheringStarted()
|
||||
"onTetheringFailed" -> callback?.onTetheringFailed()
|
||||
else -> ProxyBuilder.callSuper(proxy, method, args)
|
||||
if (args.isEmpty()) when (method.name) {
|
||||
"onTetheringStarted" -> return@handler callback?.onTetheringStarted()
|
||||
"onTetheringFailed" -> return@handler callback?.onTetheringFailed()
|
||||
}
|
||||
ProxyBuilder.callSuper(proxy, method, args)
|
||||
}
|
||||
}.build()
|
||||
startTetheringLegacy(Services.connectivity, type, showProvisioningUi, proxy, handler)
|
||||
@@ -275,13 +258,9 @@ object TetheringManager {
|
||||
arrayOf(interfaceStartTetheringCallback), object : InvocationHandler {
|
||||
override fun invoke(proxy: Any, method: Method, args: Array<out Any?>?): Any? {
|
||||
@Suppress("NAME_SHADOWING") val callback = reference.get()
|
||||
return when (val name = method.name) {
|
||||
"onTetheringStarted" -> {
|
||||
if (!args.isNullOrEmpty()) Timber.w("Unexpected args for $name: $args")
|
||||
callback?.onTetheringStarted()
|
||||
}
|
||||
"onTetheringFailed" -> {
|
||||
if (args?.size != 1) Timber.w("Unexpected args for $name: $args")
|
||||
return when {
|
||||
method.matches("onTetheringStarted") -> callback?.onTetheringStarted()
|
||||
method.matches("onTetheringFailed", Integer.TYPE) -> {
|
||||
callback?.onTetheringFailed(args?.get(0) as Int)
|
||||
}
|
||||
else -> callSuper(interfaceStartTetheringCallback, proxy, method, args)
|
||||
@@ -312,7 +291,6 @@ object TetheringManager {
|
||||
* configures tethering with the preferred local IPv4 link address to use.
|
||||
* *@see setStaticIpv4Addresses
|
||||
*/
|
||||
@RequiresApi(24)
|
||||
fun startTethering(type: Int, showProvisioningUi: Boolean, callback: StartTetheringCallback,
|
||||
handler: Handler? = null, cacheDir: File = app.deviceStorage.codeCacheDir) {
|
||||
if (Build.VERSION.SDK_INT >= 30) try {
|
||||
@@ -384,12 +362,10 @@ object TetheringManager {
|
||||
* {@link ConnectivityManager.TETHERING_USB}, or
|
||||
* {@link ConnectivityManager.TETHERING_BLUETOOTH}.
|
||||
*/
|
||||
@RequiresApi(24)
|
||||
fun stopTethering(type: Int) {
|
||||
if (Build.VERSION.SDK_INT >= 30) stopTethering(instance, type)
|
||||
else stopTetheringLegacy(Services.connectivity, type)
|
||||
}
|
||||
@RequiresApi(24)
|
||||
fun stopTethering(type: Int, callback: (Exception) -> Unit) {
|
||||
try {
|
||||
stopTethering(type)
|
||||
@@ -423,6 +399,23 @@ object TetheringManager {
|
||||
*/
|
||||
fun onTetheringSupported(supported: Boolean) {}
|
||||
|
||||
/**
|
||||
* Called when tethering supported status changed.
|
||||
*
|
||||
* This will be called immediately after the callback is registered, and may be called
|
||||
* multiple times later upon changes.
|
||||
*
|
||||
* Tethering may be disabled via system properties, device configuration, or device
|
||||
* policy restrictions.
|
||||
*
|
||||
* @param supportedTypes a set of @TetheringType which is supported.
|
||||
*/
|
||||
@TargetApi(31)
|
||||
fun onSupportedTetheringTypes(supportedTypes: Set<Int?>) {
|
||||
if ((supportedTypes - expectedTypes).isNotEmpty()) Timber.w(Exception(
|
||||
"Unexpected supported tethering types: ${supportedTypes.joinToString()}"))
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when tethering upstream changed.
|
||||
*
|
||||
@@ -445,7 +438,7 @@ object TetheringManager {
|
||||
* *@param reg The new regular expressions.
|
||||
* @hide
|
||||
*/
|
||||
fun onTetherableInterfaceRegexpsChanged(args: Array<out Any?>?) {}
|
||||
fun onTetherableInterfaceRegexpsChanged(reg: Any?) {}
|
||||
|
||||
/**
|
||||
* Called when there was a change in the list of tetherable interfaces. Tetherable
|
||||
@@ -507,11 +500,11 @@ object TetheringManager {
|
||||
Class.forName("android.net.TetheringManager\$TetheringEventCallback")
|
||||
}
|
||||
@get:RequiresApi(30)
|
||||
private val registerTetheringEventCallback by lazy {
|
||||
private val registerTetheringEventCallback by lazy @TargetApi(30) {
|
||||
clazz.getDeclaredMethod("registerTetheringEventCallback", Executor::class.java, interfaceTetheringEventCallback)
|
||||
}
|
||||
@get:RequiresApi(30)
|
||||
private val unregisterTetheringEventCallback by lazy {
|
||||
private val unregisterTetheringEventCallback by lazy @TargetApi(30) {
|
||||
clazz.getDeclaredMethod("unregisterTetheringEventCallback", interfaceTetheringEventCallback)
|
||||
}
|
||||
|
||||
@@ -541,40 +534,38 @@ object TetheringManager {
|
||||
override fun invoke(proxy: Any, method: Method, args: Array<out Any?>?): Any? {
|
||||
@Suppress("NAME_SHADOWING")
|
||||
val callback = reference.get()
|
||||
val noArgs = args?.size ?: 0
|
||||
return when (val name = method.name) {
|
||||
"onTetheringSupported" -> {
|
||||
if (noArgs != 1) Timber.w("Unexpected args for $name: $args")
|
||||
return when {
|
||||
method.matches("onTetheringSupported", Boolean::class.java) -> {
|
||||
callback?.onTetheringSupported(args!![0] as Boolean)
|
||||
}
|
||||
"onUpstreamChanged" -> {
|
||||
if (noArgs != 1) Timber.w("Unexpected args for $name: $args")
|
||||
method.matches1<java.util.Set<*>>("onSupportedTetheringTypes") -> {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
callback?.onSupportedTetheringTypes(args!![0] as Set<Int?>)
|
||||
}
|
||||
method.matches1<Network>("onUpstreamChanged") -> {
|
||||
callback?.onUpstreamChanged(args!![0] as Network?)
|
||||
}
|
||||
"onTetherableInterfaceRegexpsChanged" -> {
|
||||
if (regexpsSent) callback?.onTetherableInterfaceRegexpsChanged(args)
|
||||
method.name == "onTetherableInterfaceRegexpsChanged" &&
|
||||
method.parameters.singleOrNull()?.type?.name ==
|
||||
"android.net.TetheringManager\$TetheringInterfaceRegexps" -> {
|
||||
if (regexpsSent) callback?.onTetherableInterfaceRegexpsChanged(args!!.single())
|
||||
regexpsSent = true
|
||||
}
|
||||
"onTetherableInterfacesChanged" -> {
|
||||
if (noArgs != 1) Timber.w("Unexpected args for $name: $args")
|
||||
method.matches1<java.util.List<*>>("onTetherableInterfacesChanged") -> {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
callback?.onTetherableInterfacesChanged(args!![0] as List<String?>)
|
||||
}
|
||||
"onTetheredInterfacesChanged" -> {
|
||||
if (noArgs != 1) Timber.w("Unexpected args for $name: $args")
|
||||
method.matches1<java.util.List<*>>("onTetheredInterfacesChanged") -> {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
callback?.onTetheredInterfacesChanged(args!![0] as List<String?>)
|
||||
}
|
||||
"onError" -> {
|
||||
if (noArgs != 2) Timber.w("Unexpected args for $name: $args")
|
||||
method.matches("onError", String::class.java, Integer.TYPE) -> {
|
||||
callback?.onError(args!![0] as String, args[1] as Int)
|
||||
}
|
||||
"onClientsChanged" -> {
|
||||
if (noArgs != 1) Timber.w("Unexpected args for $name: $args")
|
||||
method.matches1<java.util.Collection<*>>("onClientsChanged") -> {
|
||||
callback?.onClientsChanged(args!![0] as Collection<*>)
|
||||
}
|
||||
"onOffloadStatusChanged" -> {
|
||||
if (noArgs != 1) Timber.w("Unexpected args for $name: $args")
|
||||
method.matches("onOffloadStatusChanged", Integer.TYPE) -> {
|
||||
callback?.onOffloadStatusChanged(args!![0] as Int)
|
||||
}
|
||||
else -> callSuper(interfaceTetheringEventCallback, proxy, method, args)
|
||||
@@ -596,7 +587,11 @@ object TetheringManager {
|
||||
@RequiresApi(30)
|
||||
fun unregisterTetheringEventCallback(callback: TetheringEventCallback) {
|
||||
val proxy = synchronized(callbackMap) { callbackMap.remove(callback) } ?: return
|
||||
try {
|
||||
unregisterTetheringEventCallback(instance, proxy)
|
||||
} catch (e: InvocationTargetException) {
|
||||
if (!e.targetException.let { it is IllegalStateException && it.cause is DeadObjectException }) throw e
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -636,14 +631,11 @@ object TetheringManager {
|
||||
"TETHER_ERROR_UNSUPPORTED", "TETHER_ERROR_UNAVAIL_IFACE", "TETHER_ERROR_MASTER_ERROR",
|
||||
"TETHER_ERROR_TETHER_IFACE_ERROR", "TETHER_ERROR_UNTETHER_IFACE_ERROR", "TETHER_ERROR_ENABLE_NAT_ERROR",
|
||||
"TETHER_ERROR_DISABLE_NAT_ERROR", "TETHER_ERROR_IFACE_CFG_ERROR", "TETHER_ERROR_PROVISION_FAILED",
|
||||
"TETHER_ERROR_DHCPSERVER_ERROR", "TETHER_ERROR_ENTITLEMENT_UNKNOWN") { clazz }
|
||||
"TETHER_ERROR_DHCPSERVER_ERROR", "TETHER_ERROR_ENTITLEMENT_UNKNOWN") @TargetApi(30) { clazz }
|
||||
@RequiresApi(30)
|
||||
const val TETHER_ERROR_NO_CHANGE_TETHERING_PERMISSION = 14
|
||||
|
||||
val Intent.tetheredIfaces get() = getStringArrayListExtra(
|
||||
if (Build.VERSION.SDK_INT >= 26) EXTRA_ACTIVE_TETHER else EXTRA_ACTIVE_TETHER_LEGACY)
|
||||
val Intent.localOnlyTetheredIfaces get() = if (Build.VERSION.SDK_INT >= 26) {
|
||||
getStringArrayListExtra(
|
||||
val Intent.tetheredIfaces get() = getStringArrayListExtra(EXTRA_ACTIVE_TETHER)
|
||||
val Intent.localOnlyTetheredIfaces get() = getStringArrayListExtra(
|
||||
if (Build.VERSION.SDK_INT >= 30) EXTRA_ACTIVE_LOCAL_ONLY else EXTRA_ACTIVE_LOCAL_ONLY_LEGACY)
|
||||
} else emptyList<String>()
|
||||
}
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
package be.mygod.vpnhotspot.net.monitor
|
||||
|
||||
import android.annotation.TargetApi
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.LinkProperties
|
||||
import android.net.Network
|
||||
import android.net.NetworkCapabilities
|
||||
import android.os.Build
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import be.mygod.vpnhotspot.util.Services
|
||||
import be.mygod.vpnhotspot.util.globalNetworkRequestBuilder
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@@ -20,10 +18,10 @@ object DefaultNetworkMonitor : UpstreamMonitor() {
|
||||
* Unfortunately registerDefaultNetworkCallback is going to return VPN interface since Android P DP1:
|
||||
* https://android.googlesource.com/platform/frameworks/base/+/dda156ab0c5d66ad82bdcf76cda07cbc0a9c8a2e
|
||||
*/
|
||||
private val networkRequest = networkRequestBuilder()
|
||||
.addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
|
||||
.addCapability(NetworkCapabilities.NET_CAPABILITY_NOT_RESTRICTED)
|
||||
.build()
|
||||
private val networkRequest = globalNetworkRequestBuilder().apply {
|
||||
addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)
|
||||
addCapability(NetworkCapabilities.NET_CAPABILITY_NOT_RESTRICTED)
|
||||
}.build()
|
||||
private val networkCallback = object : ConnectivityManager.NetworkCallback() {
|
||||
override fun onAvailable(network: Network) {
|
||||
val properties = Services.connectivity.getLinkProperties(network)
|
||||
@@ -53,23 +51,10 @@ object DefaultNetworkMonitor : UpstreamMonitor() {
|
||||
callback.onAvailable(currentLinkProperties)
|
||||
}
|
||||
} else {
|
||||
when (Build.VERSION.SDK_INT) {
|
||||
in 31..Int.MAX_VALUE -> @TargetApi(31) {
|
||||
if (Build.VERSION.SDK_INT >= 31) {
|
||||
Services.connectivity.registerBestMatchingNetworkCallback(networkRequest, networkCallback,
|
||||
Handler(Looper.getMainLooper()))
|
||||
}
|
||||
in 24..27 -> @TargetApi(24) {
|
||||
Services.connectivity.registerDefaultNetworkCallback(networkCallback)
|
||||
}
|
||||
else -> try {
|
||||
Services.connectivity.requestNetwork(networkRequest, networkCallback)
|
||||
} catch (e: SecurityException) {
|
||||
// SecurityException would be thrown in requestNetwork on Android 6.0 thanks to Google's stupid bug
|
||||
if (Build.VERSION.SDK_INT != 23) throw e
|
||||
GlobalScope.launch { callback.onFallback() }
|
||||
return
|
||||
}
|
||||
}
|
||||
Services.mainHandler)
|
||||
} else Services.connectivity.requestNetwork(networkRequest, networkCallback, Services.mainHandler)
|
||||
registered = true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import android.net.Network
|
||||
import android.net.NetworkCapabilities
|
||||
import be.mygod.vpnhotspot.util.Services
|
||||
import be.mygod.vpnhotspot.util.allInterfaceNames
|
||||
import be.mygod.vpnhotspot.util.globalNetworkRequestBuilder
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.launch
|
||||
import timber.log.Timber
|
||||
@@ -18,7 +19,7 @@ class InterfaceMonitor(private val ifaceRegex: String) : UpstreamMonitor() {
|
||||
Timber.d(e);
|
||||
{ it == ifaceRegex }
|
||||
}
|
||||
private val request = networkRequestBuilder().apply {
|
||||
private val request = globalNetworkRequestBuilder().apply {
|
||||
removeCapability(NetworkCapabilities.NET_CAPABILITY_NOT_RESTRICTED)
|
||||
removeCapability(NetworkCapabilities.NET_CAPABILITY_TRUSTED)
|
||||
removeCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN)
|
||||
@@ -76,7 +77,7 @@ class InterfaceMonitor(private val ifaceRegex: String) : UpstreamMonitor() {
|
||||
callback.onAvailable(currentLinkProperties)
|
||||
}
|
||||
} else {
|
||||
Services.connectivity.registerNetworkCallback(request, networkCallback)
|
||||
Services.registerNetworkCallback(request, networkCallback)
|
||||
registered = true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ import androidx.core.content.edit
|
||||
import be.mygod.librootkotlinx.RootServer
|
||||
import be.mygod.librootkotlinx.isEBADF
|
||||
import be.mygod.vpnhotspot.App.Companion.app
|
||||
import be.mygod.vpnhotspot.BuildConfig
|
||||
import be.mygod.vpnhotspot.R
|
||||
import be.mygod.vpnhotspot.net.Routing
|
||||
import be.mygod.vpnhotspot.root.ProcessData
|
||||
@@ -25,12 +24,12 @@ abstract class IpMonitor {
|
||||
companion object {
|
||||
const val KEY = "service.ipMonitor"
|
||||
// https://android.googlesource.com/platform/external/iproute2/+/7f7a711/lib/libnetlink.c#493
|
||||
private val errorMatcher = ("(^Cannot bind netlink socket: |" +
|
||||
private val errorMatcher = ("(?:^Cannot (?:bind netlink socket|send dump request): |^request send failed: |" +
|
||||
"Dump (was interrupted and may be inconsistent.|terminated)$)").toRegex()
|
||||
var currentMode: Mode
|
||||
get() {
|
||||
val isLegacy = Build.VERSION.SDK_INT < 30 || BuildConfig.TARGET_SDK < 30
|
||||
val defaultMode = if (isLegacy) @Suppress("DEPRECATION") {
|
||||
// Completely restricted on Android 13: https://github.com/termux/termux-app/issues/2993#issuecomment-1250312777
|
||||
val defaultMode = if (Build.VERSION.SDK_INT < 30) @Suppress("DEPRECATION") {
|
||||
Mode.Poll
|
||||
} else Mode.MonitorRoot
|
||||
return Mode.valueOf(app.pref.getString(KEY, defaultMode.toString()) ?: "")
|
||||
@@ -114,8 +113,8 @@ abstract class IpMonitor {
|
||||
try {
|
||||
RootManager.use { server ->
|
||||
// while we only need to use this server once, we need to also keep the server alive
|
||||
handleChannel(server.create(ProcessListener(errorMatcher, Routing.IP, "monitor", monitoredObject),
|
||||
this))
|
||||
handleChannel(server.create(ProcessListener(errorMatcher,
|
||||
Routing.IP, "monitor", monitoredObject), this))
|
||||
}
|
||||
} catch (_: CancellationException) {
|
||||
} catch (e: Exception) {
|
||||
@@ -152,7 +151,7 @@ abstract class IpMonitor {
|
||||
fun flushAsync() = GlobalScope.launch(Dispatchers.IO) { flush() }
|
||||
|
||||
private suspend fun work(server: RootServer?): RootServer? {
|
||||
if (currentMode != Mode.PollRoot) try {
|
||||
if (currentMode != Mode.PollRoot && currentMode != Mode.MonitorRoot) try {
|
||||
poll()
|
||||
return server
|
||||
} catch (e: IOException) {
|
||||
|
||||
@@ -33,30 +33,41 @@ class TetherTimeoutMonitor(private val timeout: Long = 0,
|
||||
private const val MIN_SOFT_AP_TIMEOUT_DELAY_MS = 600_000 // 10 minutes
|
||||
|
||||
@Deprecated("Use SoftApConfigurationCompat instead")
|
||||
@get:RequiresApi(28)
|
||||
val enabled get() = Settings.Global.getInt(app.contentResolver, SOFT_AP_TIMEOUT_ENABLED, 1) == 1
|
||||
@Deprecated("Use SoftApConfigurationCompat instead")
|
||||
suspend fun setEnabled(value: Boolean) = SettingsGlobalPut.int(SOFT_AP_TIMEOUT_ENABLED, if (value) 1 else 0)
|
||||
|
||||
val defaultTimeout: Int get() {
|
||||
val delay = if (Build.VERSION.SDK_INT >= 28) try {
|
||||
val delay = try {
|
||||
if (Build.VERSION.SDK_INT < 30) Resources.getSystem().run {
|
||||
getInteger(getIdentifier("config_wifi_framework_soft_ap_timeout_delay", "integer", "android"))
|
||||
} else {
|
||||
val info = WifiApManager.resolvedActivity.activityInfo
|
||||
val resources = app.packageManager.getResourcesForApplication(info.applicationInfo)
|
||||
resources.getInteger(resources.findIdentifier("config_wifiFrameworkSoftApShutDownTimeoutMilliseconds",
|
||||
"integer", WifiApManager.RESOURCES_PACKAGE, info.packageName))
|
||||
resources.getInteger(resources.findIdentifier(
|
||||
"config_wifiFrameworkSoftApShutDownTimeoutMilliseconds", "integer",
|
||||
WifiApManager.RESOURCES_PACKAGE, info.packageName))
|
||||
}
|
||||
} catch (e: RuntimeException) {
|
||||
Timber.w(e)
|
||||
MIN_SOFT_AP_TIMEOUT_DELAY_MS
|
||||
} else MIN_SOFT_AP_TIMEOUT_DELAY_MS
|
||||
}
|
||||
return if (Build.VERSION.SDK_INT < 30 && delay < MIN_SOFT_AP_TIMEOUT_DELAY_MS) {
|
||||
Timber.w("Overriding timeout delay with minimum limit value: $delay < $MIN_SOFT_AP_TIMEOUT_DELAY_MS")
|
||||
MIN_SOFT_AP_TIMEOUT_DELAY_MS
|
||||
} else delay
|
||||
}
|
||||
@get:RequiresApi(31)
|
||||
val defaultTimeoutBridged: Int get() = try {
|
||||
val info = WifiApManager.resolvedActivity.activityInfo
|
||||
val resources = app.packageManager.getResourcesForApplication(info.applicationInfo)
|
||||
resources.getInteger(resources.findIdentifier(
|
||||
"config_wifiFrameworkSoftApShutDownIdleInstanceInBridgedModeTimeoutMillisecond", "integer",
|
||||
WifiApManager.RESOURCES_PACKAGE, info.packageName))
|
||||
} catch (e: RuntimeException) {
|
||||
Timber.w(e)
|
||||
MIN_SOFT_AP_TIMEOUT_DELAY_MS
|
||||
}
|
||||
}
|
||||
|
||||
private var noClient = true
|
||||
@@ -74,7 +85,7 @@ class TetherTimeoutMonitor(private val timeout: Long = 0,
|
||||
fun onClientsChanged(noClient: Boolean) {
|
||||
this.noClient = noClient
|
||||
if (!noClient) close() else if (timeoutJob == null) timeoutJob = GlobalScope.launch(context) {
|
||||
delay(if (timeout == 0L) defaultTimeout.toLong() else timeout)
|
||||
delay(if (timeout <= 0L) defaultTimeout.toLong() else timeout)
|
||||
onTimeout()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
package be.mygod.vpnhotspot.net.monitor
|
||||
|
||||
import android.net.MacAddress
|
||||
import androidx.collection.LongSparseArray
|
||||
import androidx.collection.set
|
||||
import be.mygod.vpnhotspot.net.IpDev
|
||||
import be.mygod.vpnhotspot.net.MacAddressCompat
|
||||
import be.mygod.vpnhotspot.net.Routing.Companion.IPTABLES
|
||||
import be.mygod.vpnhotspot.room.AppDatabase
|
||||
import be.mygod.vpnhotspot.room.TrafficRecord
|
||||
@@ -11,7 +11,12 @@ import be.mygod.vpnhotspot.util.Event2
|
||||
import be.mygod.vpnhotspot.util.RootSession
|
||||
import be.mygod.vpnhotspot.util.parseNumericAddress
|
||||
import be.mygod.vpnhotspot.widget.SmartSnackbar
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineStart
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import timber.log.Timber
|
||||
import java.net.InetAddress
|
||||
import java.util.concurrent.TimeUnit
|
||||
@@ -23,8 +28,8 @@ object TrafficRecorder {
|
||||
private val records = mutableMapOf<IpDev, TrafficRecord>()
|
||||
val foregroundListeners = Event2<Collection<TrafficRecord>, LongSparseArray<TrafficRecord>>()
|
||||
|
||||
fun register(ip: InetAddress, downstream: String, mac: MacAddressCompat) {
|
||||
val record = TrafficRecord(mac = mac.addr, ip = ip, downstream = downstream)
|
||||
fun register(ip: InetAddress, downstream: String, mac: MacAddress) {
|
||||
val record = TrafficRecord(mac = mac, ip = ip, downstream = downstream)
|
||||
AppDatabase.instance.trafficRecordDao.insert(record)
|
||||
synchronized(this) {
|
||||
val key = IpDev(ip, downstream)
|
||||
@@ -107,9 +112,9 @@ object TrafficRecorder {
|
||||
record.sentBytes = columns[1].toLong()
|
||||
}
|
||||
}
|
||||
if (oldRecord.id != null) {
|
||||
oldRecord.id?.let { oldId ->
|
||||
check(records.put(key, record) == oldRecord)
|
||||
oldRecords[oldRecord.id!!] = oldRecord
|
||||
oldRecords[oldId] = oldRecord
|
||||
}
|
||||
}
|
||||
else -> check(false)
|
||||
@@ -130,6 +135,7 @@ object TrafficRecorder {
|
||||
}
|
||||
fun update(timeout: Boolean = false) {
|
||||
synchronized(this) {
|
||||
unscheduleUpdateLocked()
|
||||
if (records.isEmpty()) return
|
||||
val timestamp = System.currentTimeMillis()
|
||||
if (!timeout && timestamp - lastUpdate <= 100) return
|
||||
@@ -141,7 +147,6 @@ object TrafficRecorder {
|
||||
SmartSnackbar.make(e).show()
|
||||
}
|
||||
lastUpdate = timestamp
|
||||
updateJob = null
|
||||
scheduleUpdateLocked()
|
||||
}
|
||||
}
|
||||
@@ -156,5 +161,5 @@ object TrafficRecorder {
|
||||
/**
|
||||
* Possibly inefficient. Don't call this too often.
|
||||
*/
|
||||
fun isWorking(mac: MacAddressCompat) = records.values.any { it.mac == mac.addr }
|
||||
fun isWorking(mac: MacAddress) = records.values.any { it.mac == mac }
|
||||
}
|
||||
|
||||
@@ -2,9 +2,6 @@ package be.mygod.vpnhotspot.net.monitor
|
||||
|
||||
import android.content.SharedPreferences
|
||||
import android.net.LinkProperties
|
||||
import android.net.NetworkCapabilities
|
||||
import android.net.NetworkRequest
|
||||
import android.os.Build
|
||||
import be.mygod.vpnhotspot.App.Companion.app
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -23,13 +20,6 @@ abstract class UpstreamMonitor {
|
||||
}
|
||||
private var monitor = generateMonitor()
|
||||
|
||||
fun networkRequestBuilder() = NetworkRequest.Builder().apply {
|
||||
if (Build.VERSION.SDK_INT == 23) { // workarounds for OEM bugs
|
||||
removeCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED)
|
||||
removeCapability(NetworkCapabilities.NET_CAPABILITY_CAPTIVE_PORTAL)
|
||||
}
|
||||
}
|
||||
|
||||
fun registerCallback(callback: Callback) = synchronized(this) { monitor.registerCallback(callback) }
|
||||
fun unregisterCallback(callback: Callback) = synchronized(this) { monitor.unregisterCallback(callback) }
|
||||
|
||||
@@ -56,15 +46,6 @@ abstract class UpstreamMonitor {
|
||||
* Called if some possibly stacked interface is available
|
||||
*/
|
||||
fun onAvailable(properties: LinkProperties? = null)
|
||||
/**
|
||||
* Called on API 23- from DefaultNetworkMonitor. This indicates that there isn't a good way of telling the
|
||||
* default network (see DefaultNetworkMonitor) and we are using rules at priority 22000
|
||||
* (RULE_PRIORITY_DEFAULT_NETWORK) as our fallback rules, which would work fine until Android 9.0 broke it in
|
||||
* commit: https://android.googlesource.com/platform/system/netd/+/758627c4d93392190b08e9aaea3bbbfb92a5f364
|
||||
*/
|
||||
fun onFallback() {
|
||||
throw UnsupportedOperationException()
|
||||
}
|
||||
}
|
||||
|
||||
val callbacks = mutableSetOf<Callback>()
|
||||
|
||||
@@ -5,15 +5,16 @@ import android.net.LinkProperties
|
||||
import android.net.Network
|
||||
import android.net.NetworkCapabilities
|
||||
import be.mygod.vpnhotspot.util.Services
|
||||
import be.mygod.vpnhotspot.util.globalNetworkRequestBuilder
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.launch
|
||||
import timber.log.Timber
|
||||
|
||||
object VpnMonitor : UpstreamMonitor() {
|
||||
private val request = networkRequestBuilder()
|
||||
.addTransportType(NetworkCapabilities.TRANSPORT_VPN)
|
||||
.removeCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN)
|
||||
.build()
|
||||
private val request = globalNetworkRequestBuilder().apply {
|
||||
addTransportType(NetworkCapabilities.TRANSPORT_VPN)
|
||||
removeCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN)
|
||||
}.build()
|
||||
private var registered = false
|
||||
|
||||
private val available = HashMap<Network, LinkProperties?>()
|
||||
@@ -60,7 +61,7 @@ object VpnMonitor : UpstreamMonitor() {
|
||||
callback.onAvailable(currentLinkProperties)
|
||||
}
|
||||
} else {
|
||||
Services.connectivity.registerNetworkCallback(request, networkCallback)
|
||||
Services.registerNetworkCallback(request, networkCallback)
|
||||
registered = true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package be.mygod.vpnhotspot.net.wifi
|
||||
|
||||
import android.net.MacAddress
|
||||
import android.net.wifi.p2p.WifiP2pGroup
|
||||
import be.mygod.vpnhotspot.RepeaterService
|
||||
import be.mygod.vpnhotspot.net.MacAddressCompat
|
||||
@@ -53,8 +54,8 @@ class P2pSupplicantConfiguration(private val group: WifiP2pGroup? = null) {
|
||||
var bssids = listOfNotNull(group?.owner?.deviceAddress, ownerAddress)
|
||||
.distinct()
|
||||
.filter {
|
||||
val mac = MacAddress.fromString(it)
|
||||
try {
|
||||
val mac = MacAddressCompat.fromString(it)
|
||||
mac != MacAddressCompat.ALL_ZEROS_ADDRESS && mac != MacAddressCompat.ANY_ADDRESS
|
||||
} catch (_: IllegalArgumentException) {
|
||||
false
|
||||
@@ -75,7 +76,13 @@ class P2pSupplicantConfiguration(private val group: WifiP2pGroup? = null) {
|
||||
if (matchedBssid.isEmpty()) {
|
||||
check(block.pskLine == null && block.psk == null)
|
||||
if (match.groups[5] != null) {
|
||||
block.psk = match.groupValues[5].apply { check(length in 8..63) }
|
||||
block.psk = match.groupValues[5].apply {
|
||||
when (length) {
|
||||
in 8..63 -> { }
|
||||
64 -> error("WPA-PSK hex not supported")
|
||||
else -> error("Unknown length $length")
|
||||
}
|
||||
}
|
||||
}
|
||||
block.pskLine = block.size
|
||||
} else if (bssids.any { matchedBssid.equals(it, true) }) {
|
||||
@@ -120,7 +127,7 @@ class P2pSupplicantConfiguration(private val group: WifiP2pGroup? = null) {
|
||||
add("\tmode=3")
|
||||
add("\tdisabled=2")
|
||||
add("}")
|
||||
if (target == null) target = this
|
||||
target = this
|
||||
})
|
||||
}
|
||||
content = Content(result, target!!, persistentMacLine, legacy)
|
||||
@@ -135,13 +142,12 @@ class P2pSupplicantConfiguration(private val group: WifiP2pGroup? = null) {
|
||||
}
|
||||
val psk by lazy { group?.passphrase ?: content.target.psk!! }
|
||||
val bssid by lazy {
|
||||
content.target.bssid?.let { MacAddressCompat.fromString(it) }
|
||||
content.target.bssid?.let { MacAddress.fromString(it) }
|
||||
}
|
||||
|
||||
suspend fun update(ssid: String, psk: String, bssid: MacAddressCompat?) {
|
||||
suspend fun update(ssid: WifiSsidCompat, psk: String, bssid: MacAddress?) {
|
||||
val (lines, block, persistentMacLine, legacy) = content
|
||||
block[block.ssidLine!!] = "\tssid=" + ssid.toByteArray()
|
||||
.joinToString("") { (it.toInt() and 255).toString(16).padStart(2, '0') }
|
||||
block[block.ssidLine!!] = "\tssid=${ssid.hex}"
|
||||
block[block.pskLine!!] = "\tpsk=\"$psk\"" // no control chars or weird stuff
|
||||
if (bssid != null) {
|
||||
persistentMacLine?.let { lines[it] = PERSISTENT_MAC + bssid }
|
||||
|
||||
@@ -1,20 +1,27 @@
|
||||
package be.mygod.vpnhotspot.net.wifi
|
||||
|
||||
import android.annotation.TargetApi
|
||||
import android.os.Build
|
||||
import android.os.Parcelable
|
||||
import androidx.annotation.RequiresApi
|
||||
import be.mygod.vpnhotspot.util.LongConstantLookup
|
||||
import be.mygod.vpnhotspot.util.UnblockCentral
|
||||
import timber.log.Timber
|
||||
|
||||
@JvmInline
|
||||
@RequiresApi(30)
|
||||
value class SoftApCapability(val inner: Parcelable) {
|
||||
companion object {
|
||||
private val clazz by lazy { Class.forName("android.net.wifi.SoftApCapability") }
|
||||
val clazz by lazy { Class.forName("android.net.wifi.SoftApCapability") }
|
||||
private val getMaxSupportedClients by lazy { clazz.getDeclaredMethod("getMaxSupportedClients") }
|
||||
private val areFeaturesSupported by lazy { clazz.getDeclaredMethod("areFeaturesSupported", Long::class.java) }
|
||||
@get:RequiresApi(31)
|
||||
private val getSupportedChannelList by lazy {
|
||||
clazz.getDeclaredMethod("getSupportedChannelList", Int::class.java)
|
||||
}
|
||||
@get:RequiresApi(31)
|
||||
@get:TargetApi(33)
|
||||
private val getCountryCode by lazy { UnblockCentral.getCountryCode(clazz) }
|
||||
|
||||
@RequiresApi(31)
|
||||
const val SOFTAP_FEATURE_BAND_24G_SUPPORTED = 32L
|
||||
@@ -38,4 +45,11 @@ value class SoftApCapability(val inner: Parcelable) {
|
||||
return supportedFeatures
|
||||
}
|
||||
fun getSupportedChannelList(band: Int) = getSupportedChannelList(inner, band) as IntArray
|
||||
@get:RequiresApi(31)
|
||||
val countryCode: String? get() = try {
|
||||
getCountryCode(inner) as String?
|
||||
} catch (e: ReflectiveOperationException) {
|
||||
if (Build.VERSION.SDK_INT >= 33) Timber.w(e)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,43 +3,39 @@ package be.mygod.vpnhotspot.net.wifi
|
||||
import android.annotation.SuppressLint
|
||||
import android.annotation.TargetApi
|
||||
import android.net.MacAddress
|
||||
import android.net.wifi.ScanResult
|
||||
import android.net.wifi.SoftApConfiguration
|
||||
import android.net.wifi.WifiSsid
|
||||
import android.os.Build
|
||||
import android.os.Parcelable
|
||||
import android.util.SparseIntArray
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.core.os.BuildCompat
|
||||
import androidx.core.util.keyIterator
|
||||
import be.mygod.vpnhotspot.net.MacAddressCompat
|
||||
import be.mygod.vpnhotspot.net.MacAddressCompat.Companion.toCompat
|
||||
import be.mygod.vpnhotspot.net.monitor.TetherTimeoutMonitor
|
||||
import be.mygod.vpnhotspot.net.wifi.SoftApConfigurationCompat.Companion.requireSingleBand
|
||||
import be.mygod.vpnhotspot.net.wifi.SoftApConfigurationCompat.Companion.setChannel
|
||||
import be.mygod.vpnhotspot.net.wifi.WifiSsidCompat.Companion.toCompat
|
||||
import be.mygod.vpnhotspot.util.ConstantLookup
|
||||
import be.mygod.vpnhotspot.util.UnblockCentral
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import timber.log.Timber
|
||||
import java.lang.reflect.InvocationTargetException
|
||||
|
||||
@Parcelize
|
||||
data class SoftApConfigurationCompat(
|
||||
var ssid: String? = null,
|
||||
@Deprecated("Workaround for using inline class with Parcelize, use bssid")
|
||||
var bssidAddr: Long? = null,
|
||||
var ssid: WifiSsidCompat? = null,
|
||||
var bssid: MacAddress? = null,
|
||||
var passphrase: String? = null,
|
||||
var isHiddenSsid: Boolean = false,
|
||||
/**
|
||||
* To read legacy band/channel pair, use [requireSingleBand]. For easy access, see [getChannel].
|
||||
*
|
||||
* You should probably set or modify this field directly only when you want to use bridged AP,
|
||||
* see also [android.net.wifi.WifiManager.isBridgedApConcurrencySupported].
|
||||
* Otherwise, use [optimizeChannels] or [setChannel].
|
||||
* Otherwise, use [requireSingleBand] and [setChannel].
|
||||
*/
|
||||
@TargetApi(23)
|
||||
var channels: SparseIntArray = SparseIntArray(1).apply { put(BAND_2GHZ, 0) },
|
||||
var channels: SparseIntArray = SparseIntArray(1).apply { append(BAND_2GHZ, 0) },
|
||||
var securityType: Int = SoftApConfiguration.SECURITY_TYPE_OPEN,
|
||||
@TargetApi(30)
|
||||
var maxNumberOfClients: Int = 0,
|
||||
@TargetApi(28)
|
||||
var isAutoShutdownEnabled: Boolean = true,
|
||||
@TargetApi(28)
|
||||
var shutdownTimeoutMillis: Long = 0,
|
||||
@TargetApi(30)
|
||||
var isClientControlByUserEnabled: Boolean = false,
|
||||
@@ -48,14 +44,29 @@ data class SoftApConfigurationCompat(
|
||||
@RequiresApi(30)
|
||||
var allowedClientList: List<MacAddress> = emptyList(),
|
||||
@TargetApi(31)
|
||||
var macRandomizationSetting: Int = RANDOMIZATION_PERSISTENT,
|
||||
var macRandomizationSetting: Int = if (Build.VERSION.SDK_INT >= 33) {
|
||||
RANDOMIZATION_NON_PERSISTENT
|
||||
} else RANDOMIZATION_PERSISTENT,
|
||||
@TargetApi(31)
|
||||
var isBridgedModeOpportunisticShutdownEnabled: Boolean = true,
|
||||
@TargetApi(31)
|
||||
var isIeee80211axEnabled: Boolean = true,
|
||||
@TargetApi(33)
|
||||
var isIeee80211beEnabled: Boolean = true,
|
||||
@TargetApi(31)
|
||||
var isUserConfiguration: Boolean = true,
|
||||
var underlying: Parcelable? = null) : Parcelable {
|
||||
@TargetApi(33)
|
||||
var bridgedModeOpportunisticShutdownTimeoutMillis: Long = -1L,
|
||||
@TargetApi(33)
|
||||
var vendorElements: List<ScanResult.InformationElement> = emptyList(),
|
||||
@TargetApi(33)
|
||||
var persistentRandomizedMacAddress: MacAddress? = null,
|
||||
@TargetApi(33)
|
||||
var allowedAcsChannels: Map<Int, Set<Int>> = emptyMap(),
|
||||
@TargetApi(33)
|
||||
var maxChannelBandwidth: Int = CHANNEL_WIDTH_AUTO,
|
||||
var underlying: Parcelable? = null,
|
||||
) : Parcelable {
|
||||
companion object {
|
||||
const val BAND_2GHZ = 1
|
||||
const val BAND_5GHZ = 2
|
||||
@@ -64,20 +75,32 @@ data class SoftApConfigurationCompat(
|
||||
@TargetApi(31)
|
||||
const val BAND_60GHZ = 8
|
||||
const val BAND_LEGACY = BAND_2GHZ or BAND_5GHZ
|
||||
@TargetApi(30)
|
||||
const val BAND_ANY_30 = BAND_LEGACY or BAND_6GHZ
|
||||
@TargetApi(31)
|
||||
const val BAND_ANY_31 = BAND_ANY_30 or BAND_60GHZ
|
||||
val BAND_TYPES by lazy {
|
||||
if (BuildCompat.isAtLeastS()) try {
|
||||
if (Build.VERSION.SDK_INT >= 31) try {
|
||||
return@lazy UnblockCentral.SoftApConfiguration_BAND_TYPES
|
||||
} catch (e: ReflectiveOperationException) {
|
||||
Timber.w(e)
|
||||
}
|
||||
intArrayOf(BAND_2GHZ, BAND_5GHZ, BAND_6GHZ, BAND_60GHZ)
|
||||
}
|
||||
val bandLookup = ConstantLookup<SoftApConfiguration>("BAND_", null, "2GHZ", "5GHZ")
|
||||
@RequiresApi(31)
|
||||
val bandLookup = ConstantLookup<SoftApConfiguration>("BAND_")
|
||||
|
||||
@TargetApi(31)
|
||||
const val RANDOMIZATION_NONE = 0
|
||||
@TargetApi(31)
|
||||
const val RANDOMIZATION_PERSISTENT = 1
|
||||
@TargetApi(33)
|
||||
const val RANDOMIZATION_NON_PERSISTENT = 2
|
||||
|
||||
@TargetApi(33)
|
||||
const val CHANNEL_WIDTH_AUTO = -1
|
||||
@TargetApi(30)
|
||||
const val CHANNEL_WIDTH_INVALID = 0
|
||||
|
||||
fun isLegacyEitherBand(band: Int) = band and BAND_LEGACY == BAND_LEGACY
|
||||
|
||||
@@ -86,15 +109,19 @@ data class SoftApConfigurationCompat(
|
||||
*/
|
||||
private const val LEGACY_WPA2_PSK = 4
|
||||
|
||||
val securityTypes = arrayOf("OPEN", "WPA2-PSK", "WPA3-SAE", "WPA3-SAE Transition mode")
|
||||
|
||||
private val qrSanitizer = Regex("([\\\\\":;,])")
|
||||
val securityTypes = arrayOf(
|
||||
"OPEN",
|
||||
"WPA2-PSK",
|
||||
"WPA3-SAE Transition mode",
|
||||
"WPA3-SAE",
|
||||
"WPA3-OWE Transition",
|
||||
"WPA3-OWE",
|
||||
)
|
||||
|
||||
/**
|
||||
* Based on:
|
||||
* https://elixir.bootlin.com/linux/v5.12.8/source/net/wireless/util.c#L75
|
||||
* TODO: update for Android 12
|
||||
* https://cs.android.com/android/platform/superproject/+/master:frameworks/base/wifi/java/android/net/wifi/ScanResult.java;l=624;drc=f7ccda05642b55700d67a288462bada488fc7f5e
|
||||
* https://cs.android.com/android/platform/superproject/+/master:packages/modules/Wifi/framework/java/android/net/wifi/ScanResult.java;l=789;drc=71d758698c45984d3f8de981bf98e56902480f16
|
||||
*/
|
||||
fun channelToFrequency(band: Int, chan: Int) = when (band) {
|
||||
BAND_2GHZ -> when (chan) {
|
||||
@@ -109,7 +136,7 @@ data class SoftApConfigurationCompat(
|
||||
}
|
||||
BAND_6GHZ -> when (chan) {
|
||||
2 -> 5935
|
||||
in 1..233 -> 5950 + chan * 5
|
||||
in 1..253 -> 5950 + chan * 5
|
||||
else -> throw IllegalArgumentException("Invalid 6GHz channel $chan")
|
||||
}
|
||||
BAND_60GHZ -> {
|
||||
@@ -134,7 +161,6 @@ data class SoftApConfigurationCompat(
|
||||
*
|
||||
* https://android.googlesource.com/platform/frameworks/base/+/android-6.0.0_r1/wifi/java/android/net/wifi/WifiConfiguration.java#242
|
||||
*/
|
||||
@get:RequiresApi(23)
|
||||
@Suppress("DEPRECATION")
|
||||
/**
|
||||
* The band which AP resides on
|
||||
@@ -142,7 +168,6 @@ data class SoftApConfigurationCompat(
|
||||
* By default, 2G is chosen
|
||||
*/
|
||||
private val apBand by lazy { android.net.wifi.WifiConfiguration::class.java.getDeclaredField("apBand") }
|
||||
@get:RequiresApi(23)
|
||||
@Suppress("DEPRECATION")
|
||||
/**
|
||||
* The channel which AP resides on
|
||||
@@ -154,6 +179,10 @@ data class SoftApConfigurationCompat(
|
||||
android.net.wifi.WifiConfiguration::class.java.getDeclaredField("apChannel")
|
||||
}
|
||||
|
||||
@get:RequiresApi(33)
|
||||
private val getAllowedAcsChannels by lazy @TargetApi(33) {
|
||||
SoftApConfiguration::class.java.getDeclaredMethod("getAllowedAcsChannels", Int::class.java)
|
||||
}
|
||||
@get:RequiresApi(30)
|
||||
private val getAllowedClientList by lazy @TargetApi(30) {
|
||||
SoftApConfiguration::class.java.getDeclaredMethod("getAllowedClientList")
|
||||
@@ -164,6 +193,10 @@ data class SoftApConfigurationCompat(
|
||||
private val getBlockedClientList by lazy @TargetApi(30) {
|
||||
SoftApConfiguration::class.java.getDeclaredMethod("getBlockedClientList")
|
||||
}
|
||||
@get:RequiresApi(33)
|
||||
private val getBridgedModeOpportunisticShutdownTimeoutMillis by lazy @TargetApi(33) {
|
||||
SoftApConfiguration::class.java.getDeclaredMethod("getBridgedModeOpportunisticShutdownTimeoutMillis")
|
||||
}
|
||||
@get:RequiresApi(30)
|
||||
private val getChannel by lazy @TargetApi(30) {
|
||||
SoftApConfiguration::class.java.getDeclaredMethod("getChannel")
|
||||
@@ -176,14 +209,26 @@ data class SoftApConfigurationCompat(
|
||||
private val getMacRandomizationSetting by lazy @TargetApi(31) {
|
||||
SoftApConfiguration::class.java.getDeclaredMethod("getMacRandomizationSetting")
|
||||
}
|
||||
@get:RequiresApi(33)
|
||||
private val getMaxChannelBandwidth by lazy @TargetApi(33) {
|
||||
SoftApConfiguration::class.java.getDeclaredMethod("getMaxChannelBandwidth")
|
||||
}
|
||||
@get:RequiresApi(30)
|
||||
private val getMaxNumberOfClients by lazy @TargetApi(30) {
|
||||
SoftApConfiguration::class.java.getDeclaredMethod("getMaxNumberOfClients")
|
||||
}
|
||||
@get:RequiresApi(33)
|
||||
private val getPersistentRandomizedMacAddress by lazy @TargetApi(33) {
|
||||
SoftApConfiguration::class.java.getDeclaredMethod("getPersistentRandomizedMacAddress")
|
||||
}
|
||||
@get:RequiresApi(30)
|
||||
private val getShutdownTimeoutMillis by lazy @TargetApi(30) {
|
||||
SoftApConfiguration::class.java.getDeclaredMethod("getShutdownTimeoutMillis")
|
||||
}
|
||||
@get:RequiresApi(33)
|
||||
private val getVendorElements by lazy @TargetApi(33) {
|
||||
SoftApConfiguration::class.java.getDeclaredMethod("getVendorElements")
|
||||
}
|
||||
@get:RequiresApi(30)
|
||||
private val isAutoShutdownEnabled by lazy @TargetApi(30) {
|
||||
SoftApConfiguration::class.java.getDeclaredMethod("isAutoShutdownEnabled")
|
||||
@@ -200,6 +245,10 @@ data class SoftApConfigurationCompat(
|
||||
private val isIeee80211axEnabled by lazy @TargetApi(31) {
|
||||
SoftApConfiguration::class.java.getDeclaredMethod("isIeee80211axEnabled")
|
||||
}
|
||||
@get:RequiresApi(33)
|
||||
private val isIeee80211beEnabled by lazy @TargetApi(33) {
|
||||
SoftApConfiguration::class.java.getDeclaredMethod("isIeee80211beEnabled")
|
||||
}
|
||||
@get:RequiresApi(31)
|
||||
private val isUserConfiguration by lazy @TargetApi(31) {
|
||||
SoftApConfiguration::class.java.getDeclaredMethod("isUserConfiguration")
|
||||
@@ -210,78 +259,106 @@ data class SoftApConfigurationCompat(
|
||||
@get:RequiresApi(30)
|
||||
private val newBuilder by lazy @TargetApi(30) { classBuilder.getConstructor(SoftApConfiguration::class.java) }
|
||||
@get:RequiresApi(30)
|
||||
private val build by lazy { classBuilder.getDeclaredMethod("build") }
|
||||
private val build by lazy @TargetApi(30) { classBuilder.getDeclaredMethod("build") }
|
||||
@get:RequiresApi(33)
|
||||
private val setAllowedAcsChannels by lazy @TargetApi(33) {
|
||||
classBuilder.getDeclaredMethod("setAllowedAcsChannels", Int::class.java, IntArray::class.java)
|
||||
}
|
||||
@get:RequiresApi(30)
|
||||
private val setAllowedClientList by lazy {
|
||||
private val setAllowedClientList by lazy @TargetApi(30) {
|
||||
classBuilder.getDeclaredMethod("setAllowedClientList", java.util.List::class.java)
|
||||
}
|
||||
@get:RequiresApi(30)
|
||||
private val setAutoShutdownEnabled by lazy {
|
||||
private val setAutoShutdownEnabled by lazy @TargetApi(30) {
|
||||
classBuilder.getDeclaredMethod("setAutoShutdownEnabled", Boolean::class.java)
|
||||
}
|
||||
@get:RequiresApi(30)
|
||||
private val setBand by lazy { classBuilder.getDeclaredMethod("setBand", Int::class.java) }
|
||||
private val setBand by lazy @TargetApi(30) { classBuilder.getDeclaredMethod("setBand", Int::class.java) }
|
||||
@get:RequiresApi(30)
|
||||
private val setBlockedClientList by lazy {
|
||||
private val setBlockedClientList by lazy @TargetApi(30) {
|
||||
classBuilder.getDeclaredMethod("setBlockedClientList", java.util.List::class.java)
|
||||
}
|
||||
@get:RequiresApi(31)
|
||||
private val setBridgedModeOpportunisticShutdownEnabled by lazy {
|
||||
private val setBridgedModeOpportunisticShutdownEnabled by lazy @TargetApi(31) {
|
||||
classBuilder.getDeclaredMethod("setBridgedModeOpportunisticShutdownEnabled", Boolean::class.java)
|
||||
}
|
||||
@get:RequiresApi(33)
|
||||
private val setBridgedModeOpportunisticShutdownTimeoutMillis by lazy @TargetApi(33) {
|
||||
classBuilder.getDeclaredMethod("setBridgedModeOpportunisticShutdownTimeoutMillis", Long::class.java)
|
||||
}
|
||||
@get:RequiresApi(30)
|
||||
private val setBssid by lazy @TargetApi(30) {
|
||||
classBuilder.getDeclaredMethod("setBssid", MacAddress::class.java)
|
||||
}
|
||||
@get:RequiresApi(30)
|
||||
private val setChannel by lazy {
|
||||
private val setChannel by lazy @TargetApi(30) {
|
||||
classBuilder.getDeclaredMethod("setChannel", Int::class.java, Int::class.java)
|
||||
}
|
||||
@get:RequiresApi(31)
|
||||
private val setChannels by lazy {
|
||||
private val setChannels by lazy @TargetApi(31) {
|
||||
classBuilder.getDeclaredMethod("setChannels", SparseIntArray::class.java)
|
||||
}
|
||||
@get:RequiresApi(30)
|
||||
private val setClientControlByUserEnabled by lazy {
|
||||
private val setClientControlByUserEnabled by lazy @TargetApi(30) {
|
||||
classBuilder.getDeclaredMethod("setClientControlByUserEnabled", Boolean::class.java)
|
||||
}
|
||||
@get:RequiresApi(30)
|
||||
private val setHiddenSsid by lazy { classBuilder.getDeclaredMethod("setHiddenSsid", Boolean::class.java) }
|
||||
private val setHiddenSsid by lazy @TargetApi(30) {
|
||||
classBuilder.getDeclaredMethod("setHiddenSsid", Boolean::class.java)
|
||||
}
|
||||
@get:RequiresApi(31)
|
||||
private val setIeee80211axEnabled by lazy {
|
||||
private val setIeee80211axEnabled by lazy @TargetApi(31) {
|
||||
classBuilder.getDeclaredMethod("setIeee80211axEnabled", Boolean::class.java)
|
||||
}
|
||||
@get:RequiresApi(33)
|
||||
private val setIeee80211beEnabled by lazy @TargetApi(33) {
|
||||
classBuilder.getDeclaredMethod("setIeee80211beEnabled", Boolean::class.java)
|
||||
}
|
||||
@get:RequiresApi(31)
|
||||
private val setMacRandomizationSetting by lazy {
|
||||
private val setMacRandomizationSetting by lazy @TargetApi(31) {
|
||||
classBuilder.getDeclaredMethod("setMacRandomizationSetting", Int::class.java)
|
||||
}
|
||||
@get:RequiresApi(33)
|
||||
private val setMaxChannelBandwidth by lazy @TargetApi(33) {
|
||||
classBuilder.getDeclaredMethod("setMaxChannelBandwidth", Int::class.java)
|
||||
}
|
||||
@get:RequiresApi(30)
|
||||
private val setMaxNumberOfClients by lazy {
|
||||
private val setMaxNumberOfClients by lazy @TargetApi(31) {
|
||||
classBuilder.getDeclaredMethod("setMaxNumberOfClients", Int::class.java)
|
||||
}
|
||||
@get:RequiresApi(30)
|
||||
private val setPassphrase by lazy {
|
||||
private val setPassphrase by lazy @TargetApi(30) {
|
||||
classBuilder.getDeclaredMethod("setPassphrase", String::class.java, Int::class.java)
|
||||
}
|
||||
@get:RequiresApi(33)
|
||||
private val setRandomizedMacAddress by lazy @TargetApi(33) {
|
||||
UnblockCentral.setRandomizedMacAddress(classBuilder)
|
||||
}
|
||||
@get:RequiresApi(30)
|
||||
private val setShutdownTimeoutMillis by lazy {
|
||||
private val setShutdownTimeoutMillis by lazy @TargetApi(30) {
|
||||
classBuilder.getDeclaredMethod("setShutdownTimeoutMillis", Long::class.java)
|
||||
}
|
||||
@get:RequiresApi(30)
|
||||
private val setSsid by lazy { classBuilder.getDeclaredMethod("setSsid", String::class.java) }
|
||||
@get:RequiresApi(31)
|
||||
private val setUserConfiguration by lazy @TargetApi(31) { UnblockCentral.setUserConfiguration(classBuilder) }
|
||||
private val setSsid by lazy @TargetApi(30) { classBuilder.getDeclaredMethod("setSsid", String::class.java) }
|
||||
@get:RequiresApi(33)
|
||||
private val setVendorElements by lazy @TargetApi(33) {
|
||||
classBuilder.getDeclaredMethod("setVendorElements", java.util.List::class.java)
|
||||
}
|
||||
@get:RequiresApi(33)
|
||||
private val setWifiSsid by lazy @TargetApi(33) {
|
||||
classBuilder.getDeclaredMethod("setWifiSsid", WifiSsid::class.java)
|
||||
}
|
||||
|
||||
@Deprecated("Class deprecated in framework")
|
||||
@Suppress("DEPRECATION")
|
||||
fun android.net.wifi.WifiConfiguration.toCompat() = SoftApConfigurationCompat(
|
||||
SSID,
|
||||
BSSID?.let { MacAddressCompat.fromString(it) }?.addr,
|
||||
WifiSsidCompat.fromUtf8Text(SSID),
|
||||
BSSID?.let { MacAddress.fromString(it) },
|
||||
preSharedKey,
|
||||
hiddenSSID,
|
||||
// https://cs.android.com/android/platform/superproject/+/master:frameworks/base/wifi/java/android/net/wifi/SoftApConfToXmlMigrationUtil.java;l=87;drc=aa6527cf41671d1ed417b8ebdb6b3aa614f62344
|
||||
SparseIntArray(1).apply {
|
||||
if (Build.VERSION.SDK_INT < 23) put(BAND_LEGACY, 0) else put(when (val band = apBand.getInt(this)) {
|
||||
SparseIntArray(1).also {
|
||||
it.append(when (val band = apBand.getInt(this)) {
|
||||
0 -> BAND_2GHZ
|
||||
1 -> BAND_5GHZ
|
||||
-1 -> BAND_LEGACY
|
||||
@@ -302,24 +379,27 @@ data class SoftApConfigurationCompat(
|
||||
SoftApConfiguration.SECURITY_TYPE_WPA2_PSK
|
||||
}
|
||||
android.net.wifi.WifiConfiguration.KeyMgmt.SAE -> SoftApConfiguration.SECURITY_TYPE_WPA3_SAE
|
||||
android.net.wifi.WifiConfiguration.KeyMgmt.OWE -> SoftApConfiguration.SECURITY_TYPE_WPA3_OWE
|
||||
else -> android.net.wifi.WifiConfiguration.KeyMgmt.strings
|
||||
.getOrElse<String>(selected) { "?" }.let {
|
||||
throw IllegalArgumentException("Unrecognized key management $it ($selected)")
|
||||
}
|
||||
}
|
||||
},
|
||||
isAutoShutdownEnabled = if (Build.VERSION.SDK_INT >= 28) TetherTimeoutMonitor.enabled else false,
|
||||
isAutoShutdownEnabled = TetherTimeoutMonitor.enabled,
|
||||
underlying = this)
|
||||
|
||||
@RequiresApi(30)
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
fun SoftApConfiguration.toCompat() = SoftApConfigurationCompat(
|
||||
ssid,
|
||||
bssid?.toCompat()?.addr,
|
||||
if (Build.VERSION.SDK_INT >= 33) wifiSsid?.toCompat() else @Suppress("DEPRECATION") {
|
||||
WifiSsidCompat.fromUtf8Text(ssid)
|
||||
},
|
||||
bssid,
|
||||
passphrase,
|
||||
isHiddenSsid,
|
||||
if (BuildCompat.isAtLeastS()) getChannels(this) as SparseIntArray else SparseIntArray(1).apply {
|
||||
put(getBand(this) as Int, getChannel(this) as Int)
|
||||
if (Build.VERSION.SDK_INT >= 31) getChannels(this) as SparseIntArray else SparseIntArray(1).also {
|
||||
it.append(getBand(this) as Int, getChannel(this) as Int)
|
||||
},
|
||||
securityType,
|
||||
getMaxNumberOfClients(this) as Int,
|
||||
@@ -328,49 +408,71 @@ data class SoftApConfigurationCompat(
|
||||
isClientControlByUserEnabled(this) as Boolean,
|
||||
getBlockedClientList(this) as List<MacAddress>,
|
||||
getAllowedClientList(this) as List<MacAddress>,
|
||||
getMacRandomizationSetting(this) as Int,
|
||||
isBridgedModeOpportunisticShutdownEnabled(this) as Boolean,
|
||||
isIeee80211axEnabled(this) as Boolean,
|
||||
isUserConfiguration(this) as Boolean,
|
||||
this)
|
||||
underlying = this,
|
||||
).also {
|
||||
if (Build.VERSION.SDK_INT < 31) return@also
|
||||
it.macRandomizationSetting = getMacRandomizationSetting(this) as Int
|
||||
it.isBridgedModeOpportunisticShutdownEnabled = isBridgedModeOpportunisticShutdownEnabled(this) as Boolean
|
||||
it.isIeee80211axEnabled = isIeee80211axEnabled(this) as Boolean
|
||||
it.isUserConfiguration = isUserConfiguration(this) as Boolean
|
||||
if (Build.VERSION.SDK_INT < 33) return@also
|
||||
it.isIeee80211beEnabled = isIeee80211beEnabled(this) as Boolean
|
||||
it.bridgedModeOpportunisticShutdownTimeoutMillis =
|
||||
getBridgedModeOpportunisticShutdownTimeoutMillis(this) as Long
|
||||
it.vendorElements = getVendorElements(this) as List<ScanResult.InformationElement>
|
||||
it.persistentRandomizedMacAddress = getPersistentRandomizedMacAddress(this) as MacAddress?
|
||||
it.allowedAcsChannels = BAND_TYPES.map { bandType ->
|
||||
try {
|
||||
bandType to (getAllowedAcsChannels(this, bandType) as IntArray).toSet()
|
||||
} catch (e: InvocationTargetException) {
|
||||
if (e.targetException !is IllegalArgumentException) throw e
|
||||
null
|
||||
}
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
inline var bssid: MacAddressCompat?
|
||||
get() = bssidAddr?.let { MacAddressCompat(it) }
|
||||
set(value) {
|
||||
bssidAddr = value?.addr
|
||||
}.filterNotNull().toMap()
|
||||
it.maxChannelBandwidth = getMaxChannelBandwidth(this) as Int
|
||||
}
|
||||
|
||||
/**
|
||||
* Only single band/channel can be supplied on API 23-30
|
||||
*/
|
||||
fun requireSingleBand(): Pair<Int, Int> {
|
||||
fun requireSingleBand(channels: SparseIntArray): Pair<Int, Int> {
|
||||
require(channels.size() == 1) { "Unsupported number of bands configured" }
|
||||
return channels.keyAt(0) to channels.valueAt(0)
|
||||
}
|
||||
fun getChannel(band: Int): Int {
|
||||
var result = -1
|
||||
for (b in channels.keyIterator()) if (band and b == band) {
|
||||
require(result == -1) { "Duplicate band found" }
|
||||
result = channels[b]
|
||||
}
|
||||
return result
|
||||
}
|
||||
fun setChannel(channel: Int, band: Int = BAND_LEGACY) {
|
||||
channels = SparseIntArray(1).apply { put(band, channel) }
|
||||
}
|
||||
fun optimizeChannels(channels: SparseIntArray = this.channels) {
|
||||
this.channels = SparseIntArray(channels.size()).apply {
|
||||
var setBand = 0
|
||||
for (band in channels.keyIterator()) if (channels[band] == 0) setBand = setBand or band
|
||||
if (setBand != 0) put(setBand, 0) // merge all bands into one
|
||||
for (band in channels.keyIterator()) if (band and setBand == 0) put(band, channels[band])
|
||||
}
|
||||
|
||||
@RequiresApi(30)
|
||||
private fun setChannelsCompat(builder: Any, channels: SparseIntArray) = if (Build.VERSION.SDK_INT < 31) {
|
||||
val (band, channel) = requireSingleBand(channels)
|
||||
if (channel == 0) setBand(builder, band) else setChannel(builder, channel, band)
|
||||
} else setChannels(builder, channels)
|
||||
@get:RequiresApi(30)
|
||||
private val staticBuilder by lazy @TargetApi(30) { classBuilder.newInstance() }
|
||||
@RequiresApi(30)
|
||||
fun testPlatformValidity(channels: SparseIntArray) = setChannelsCompat(staticBuilder, channels)
|
||||
@RequiresApi(30)
|
||||
fun testPlatformValidity(bssid: MacAddress) = setBssid(staticBuilder, bssid)
|
||||
@RequiresApi(33)
|
||||
fun testPlatformValidity(vendorElements: List<ScanResult.InformationElement>) =
|
||||
setVendorElements(staticBuilder, vendorElements)
|
||||
@RequiresApi(33)
|
||||
fun testPlatformValidity(band: Int, channels: IntArray) = setAllowedAcsChannels(staticBuilder, band, channels)
|
||||
@RequiresApi(33)
|
||||
fun testPlatformValidity(bandwidth: Int) = setMaxChannelBandwidth(staticBuilder, bandwidth)
|
||||
@RequiresApi(30)
|
||||
fun testPlatformTimeoutValidity(timeout: Long) = setShutdownTimeoutMillis(staticBuilder, timeout)
|
||||
@RequiresApi(33)
|
||||
fun testPlatformBridgedTimeoutValidity(timeout: Long) =
|
||||
setBridgedModeOpportunisticShutdownTimeoutMillis(staticBuilder, timeout)
|
||||
}
|
||||
|
||||
fun setMacRandomizationEnabled(enabled: Boolean) {
|
||||
macRandomizationSetting = if (enabled) RANDOMIZATION_PERSISTENT else RANDOMIZATION_NONE
|
||||
fun setChannel(channel: Int, band: Int = BAND_LEGACY) {
|
||||
channels = SparseIntArray(1).apply {
|
||||
append(when {
|
||||
channel <= 0 || band != BAND_LEGACY -> band
|
||||
channel > 14 -> BAND_5GHZ
|
||||
else -> BAND_2GHZ
|
||||
}, channel)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -383,25 +485,22 @@ data class SoftApConfigurationCompat(
|
||||
@Deprecated("Class deprecated in framework, use toPlatform().toWifiConfiguration()")
|
||||
@Suppress("DEPRECATION")
|
||||
fun toWifiConfiguration(): android.net.wifi.WifiConfiguration {
|
||||
val (band, channel) = requireSingleBand()
|
||||
val (band, channel) = requireSingleBand(channels)
|
||||
val wc = underlying as? android.net.wifi.WifiConfiguration
|
||||
val result = if (wc == null) android.net.wifi.WifiConfiguration() else android.net.wifi.WifiConfiguration(wc)
|
||||
val original = wc?.toCompat()
|
||||
result.SSID = ssid
|
||||
result.SSID = ssid?.toString()
|
||||
result.preSharedKey = passphrase
|
||||
result.hiddenSSID = isHiddenSsid
|
||||
if (Build.VERSION.SDK_INT >= 23) {
|
||||
apBand.setInt(result, when (band) {
|
||||
BAND_2GHZ -> 0
|
||||
BAND_5GHZ -> 1
|
||||
else -> {
|
||||
require(Build.VERSION.SDK_INT >= 28) { "A band must be specified on this platform" }
|
||||
require(isLegacyEitherBand(band)) { "Convert fail, unsupported band setting :$band" }
|
||||
-1
|
||||
}
|
||||
})
|
||||
apChannel.setInt(result, channel)
|
||||
} else require(isLegacyEitherBand(band)) { "Specifying band is unsupported on this platform" }
|
||||
if (original?.securityType != securityType) {
|
||||
result.allowedKeyManagement.clear()
|
||||
result.allowedKeyManagement.set(when (securityType) {
|
||||
@@ -411,6 +510,8 @@ data class SoftApConfigurationCompat(
|
||||
// CHANGED: not actually converted in framework-wifi
|
||||
SoftApConfiguration.SECURITY_TYPE_WPA3_SAE,
|
||||
SoftApConfiguration.SECURITY_TYPE_WPA3_SAE_TRANSITION -> android.net.wifi.WifiConfiguration.KeyMgmt.SAE
|
||||
SoftApConfiguration.SECURITY_TYPE_WPA3_OWE,
|
||||
SoftApConfiguration.SECURITY_TYPE_WPA3_OWE_TRANSITION -> android.net.wifi.WifiConfiguration.KeyMgmt.OWE
|
||||
else -> throw IllegalArgumentException("Convert fail, unsupported security type :$securityType")
|
||||
})
|
||||
result.allowedAuthAlgorithms.clear()
|
||||
@@ -425,29 +526,59 @@ data class SoftApConfigurationCompat(
|
||||
fun toPlatform(): SoftApConfiguration {
|
||||
val sac = underlying as? SoftApConfiguration
|
||||
val builder = if (sac == null) classBuilder.newInstance() else newBuilder.newInstance(sac)
|
||||
setSsid(builder, ssid)
|
||||
setPassphrase(builder, if (securityType == SoftApConfiguration.SECURITY_TYPE_OPEN) null else passphrase,
|
||||
securityType)
|
||||
if (BuildCompat.isAtLeastS()) setChannels(builder, channels) else {
|
||||
val (band, channel) = requireSingleBand()
|
||||
if (channel == 0) setBand(builder, band) else setChannel(builder, channel, band)
|
||||
}
|
||||
setBssid(builder, bssid?.toPlatform())
|
||||
if (Build.VERSION.SDK_INT >= 33) {
|
||||
setWifiSsid(builder, ssid?.toPlatform())
|
||||
} else setSsid(builder, ssid?.toString())
|
||||
setPassphrase(builder, when (securityType) {
|
||||
SoftApConfiguration.SECURITY_TYPE_OPEN,
|
||||
SoftApConfiguration.SECURITY_TYPE_WPA3_OWE_TRANSITION,
|
||||
SoftApConfiguration.SECURITY_TYPE_WPA3_OWE -> null
|
||||
else -> passphrase
|
||||
}, securityType)
|
||||
setChannelsCompat(builder, channels)
|
||||
setBssid(builder,
|
||||
if (Build.VERSION.SDK_INT < 31 || macRandomizationSetting == RANDOMIZATION_NONE) bssid else null)
|
||||
setMaxNumberOfClients(builder, maxNumberOfClients)
|
||||
try {
|
||||
setShutdownTimeoutMillis(builder, shutdownTimeoutMillis)
|
||||
} catch (e: InvocationTargetException) {
|
||||
if (e.targetException is IllegalArgumentException) try {
|
||||
setShutdownTimeoutMillis(builder, -1 - shutdownTimeoutMillis)
|
||||
} catch (e2: InvocationTargetException) {
|
||||
e2.addSuppressed(e)
|
||||
throw e2
|
||||
} else throw e
|
||||
}
|
||||
setAutoShutdownEnabled(builder, isAutoShutdownEnabled)
|
||||
setClientControlByUserEnabled(builder, isClientControlByUserEnabled)
|
||||
setHiddenSsid(builder, isHiddenSsid)
|
||||
setAllowedClientList(builder, allowedClientList)
|
||||
setBlockedClientList(builder, blockedClientList)
|
||||
if (BuildCompat.isAtLeastS()) {
|
||||
if (Build.VERSION.SDK_INT >= 31) {
|
||||
setMacRandomizationSetting(builder, macRandomizationSetting)
|
||||
setBridgedModeOpportunisticShutdownEnabled(builder, isBridgedModeOpportunisticShutdownEnabled)
|
||||
setIeee80211axEnabled(builder, isIeee80211axEnabled)
|
||||
if (sac?.let { isUserConfiguration(it) as Boolean } != false != isUserConfiguration) try {
|
||||
setUserConfiguration(builder, isUserConfiguration)
|
||||
if (Build.VERSION.SDK_INT >= 33) {
|
||||
setIeee80211beEnabled(builder, isIeee80211beEnabled)
|
||||
setBridgedModeOpportunisticShutdownTimeoutMillis(builder, bridgedModeOpportunisticShutdownTimeoutMillis)
|
||||
setVendorElements(builder, vendorElements)
|
||||
val needsUpdate = persistentRandomizedMacAddress != null && sac?.let {
|
||||
getPersistentRandomizedMacAddress(it) as MacAddress
|
||||
} != persistentRandomizedMacAddress
|
||||
if (needsUpdate) try {
|
||||
setRandomizedMacAddress(builder, persistentRandomizedMacAddress)
|
||||
} catch (e: ReflectiveOperationException) {
|
||||
Timber.w(e) // as far as we are concerned, this field is not used anywhere so ignore for now
|
||||
Timber.w(e)
|
||||
}
|
||||
for (bandType in BAND_TYPES) {
|
||||
val value = allowedAcsChannels[bandType] ?: emptySet()
|
||||
try {
|
||||
setAllowedAcsChannels(builder, bandType, value.toIntArray())
|
||||
} catch (e: InvocationTargetException) {
|
||||
if (value.isNotEmpty()) throw e
|
||||
}
|
||||
}
|
||||
setMaxChannelBandwidth(builder, maxChannelBandwidth)
|
||||
}
|
||||
}
|
||||
return build(builder) as SoftApConfiguration
|
||||
@@ -458,21 +589,21 @@ data class SoftApConfigurationCompat(
|
||||
* Based on: https://android.googlesource.com/platform/packages/apps/Settings/+/4a5ff58/src/com/android/settings/wifi/dpp/WifiNetworkConfig.java#161
|
||||
*/
|
||||
fun toQrCode() = StringBuilder("WIFI:").apply {
|
||||
fun String.sanitize() = qrSanitizer.replace(this) { "\\${it.groupValues[1]}" }
|
||||
when (securityType) {
|
||||
SoftApConfiguration.SECURITY_TYPE_OPEN -> { }
|
||||
SoftApConfiguration.SECURITY_TYPE_WPA2_PSK -> append("T:WPA;")
|
||||
SoftApConfiguration.SECURITY_TYPE_WPA3_SAE, SoftApConfiguration.SECURITY_TYPE_WPA3_SAE_TRANSITION -> {
|
||||
append("T:SAE;")
|
||||
SoftApConfiguration.SECURITY_TYPE_OPEN, SoftApConfiguration.SECURITY_TYPE_WPA3_OWE_TRANSITION,
|
||||
SoftApConfiguration.SECURITY_TYPE_WPA3_OWE -> { }
|
||||
SoftApConfiguration.SECURITY_TYPE_WPA2_PSK, SoftApConfiguration.SECURITY_TYPE_WPA3_SAE_TRANSITION -> {
|
||||
append("T:WPA;")
|
||||
}
|
||||
SoftApConfiguration.SECURITY_TYPE_WPA3_SAE -> append("T:SAE;")
|
||||
else -> throw IllegalArgumentException("Unsupported authentication type")
|
||||
}
|
||||
append("S:")
|
||||
append(ssid!!.sanitize())
|
||||
append(ssid!!.toMeCard())
|
||||
append(';')
|
||||
passphrase?.let { passphrase ->
|
||||
append("P:")
|
||||
append(passphrase.sanitize())
|
||||
append(WifiSsidCompat.toMeCard(passphrase))
|
||||
append(';')
|
||||
}
|
||||
if (isHiddenSsid) append("H:true;")
|
||||
|
||||
@@ -12,7 +12,7 @@ import timber.log.Timber
|
||||
@RequiresApi(30)
|
||||
value class SoftApInfo(val inner: Parcelable) {
|
||||
companion object {
|
||||
private val clazz by lazy { Class.forName("android.net.wifi.SoftApInfo") }
|
||||
val clazz by lazy { Class.forName("android.net.wifi.SoftApInfo") }
|
||||
private val getFrequency by lazy { clazz.getDeclaredMethod("getFrequency") }
|
||||
private val getBandwidth by lazy { clazz.getDeclaredMethod("getBandwidth") }
|
||||
@get:RequiresApi(31)
|
||||
@@ -30,7 +30,7 @@ value class SoftApInfo(val inner: Parcelable) {
|
||||
val frequency get() = getFrequency(inner) as Int
|
||||
val bandwidth get() = getBandwidth(inner) as Int
|
||||
@get:RequiresApi(31)
|
||||
val bssid get() = getBssid(inner) as MacAddress
|
||||
val bssid get() = getBssid(inner) as MacAddress?
|
||||
@get:RequiresApi(31)
|
||||
val wifiStandard get() = getWifiStandard(inner) as Int
|
||||
@get:RequiresApi(31)
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
package be.mygod.vpnhotspot.net.wifi
|
||||
|
||||
import android.net.wifi.ScanResult
|
||||
import androidx.annotation.RequiresApi
|
||||
import timber.log.Timber
|
||||
|
||||
@RequiresApi(33)
|
||||
object VendorElements {
|
||||
fun serialize(input: List<ScanResult.InformationElement>) = input.joinToString("\n") { element ->
|
||||
element.bytes.let { buffer ->
|
||||
StringBuilder().apply {
|
||||
while (buffer.hasRemaining()) append("%02x".format(buffer.get()))
|
||||
}.toString()
|
||||
}.also {
|
||||
if (element.id != 221 || element.idExt != 0 || it.isEmpty()) Timber.w(Exception(
|
||||
"Unexpected InformationElement ${element.id}, ${element.idExt}, $it"))
|
||||
}
|
||||
}
|
||||
|
||||
fun deserialize(input: CharSequence?) = (input ?: "").split("\n").map { line ->
|
||||
if (line.isBlank()) return@map null
|
||||
require(line.length % 2 == 0) { "Input should be hex: $line" }
|
||||
(0 until line.length / 2).map {
|
||||
Integer.parseInt(line.substring(it * 2, it * 2 + 2), 16).toByte()
|
||||
}.toByteArray()
|
||||
}.filterNotNull().map { ScanResult.InformationElement(221, 0, it) }
|
||||
}
|
||||
@@ -1,13 +1,15 @@
|
||||
package be.mygod.vpnhotspot.net.wifi
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.annotation.TargetApi
|
||||
import android.content.ClipData
|
||||
import android.content.ClipDescription
|
||||
import android.content.DialogInterface
|
||||
import android.net.MacAddress
|
||||
import android.net.wifi.SoftApConfiguration
|
||||
import android.os.Build
|
||||
import android.os.Parcelable
|
||||
import android.text.Editable
|
||||
import android.text.InputFilter
|
||||
import android.text.TextWatcher
|
||||
import android.util.Base64
|
||||
import android.util.SparseIntArray
|
||||
@@ -16,10 +18,11 @@ import android.view.View
|
||||
import android.widget.AdapterView
|
||||
import android.widget.ArrayAdapter
|
||||
import android.widget.Spinner
|
||||
import android.widget.Toast
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
import androidx.appcompat.widget.Toolbar
|
||||
import androidx.core.os.BuildCompat
|
||||
import androidx.core.os.persistableBundleOf
|
||||
import androidx.core.view.isGone
|
||||
import be.mygod.librootkotlinx.toByteArray
|
||||
import be.mygod.librootkotlinx.toParcelable
|
||||
@@ -28,14 +31,16 @@ import be.mygod.vpnhotspot.App.Companion.app
|
||||
import be.mygod.vpnhotspot.R
|
||||
import be.mygod.vpnhotspot.RepeaterService
|
||||
import be.mygod.vpnhotspot.databinding.DialogWifiApBinding
|
||||
import be.mygod.vpnhotspot.net.MacAddressCompat
|
||||
import be.mygod.vpnhotspot.net.monitor.TetherTimeoutMonitor
|
||||
import be.mygod.vpnhotspot.util.QRCodeDialog
|
||||
import be.mygod.vpnhotspot.util.RangeInput
|
||||
import be.mygod.vpnhotspot.util.readableMessage
|
||||
import be.mygod.vpnhotspot.util.showAllowingStateLoss
|
||||
import be.mygod.vpnhotspot.widget.SmartSnackbar
|
||||
import com.google.android.material.textfield.TextInputLayout
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import timber.log.Timber
|
||||
import java.text.DecimalFormat
|
||||
import java.text.DecimalFormatSymbols
|
||||
|
||||
/**
|
||||
* Based on: https://android.googlesource.com/platform/packages/apps/Settings/+/39b4674/src/com/android/settings/wifi/WifiApDialog.java
|
||||
@@ -48,26 +53,34 @@ class WifiApDialogFragment : AlertDialogFragment<WifiApDialogFragment.Arg, WifiA
|
||||
companion object {
|
||||
private const val BASE64_FLAGS = Base64.NO_PADDING or Base64.NO_WRAP
|
||||
private val nonMacChars = "[^0-9a-fA-F:]+".toRegex()
|
||||
private val baseOptions by lazy { listOf(ChannelOption.Disabled, ChannelOption.Auto) }
|
||||
private val channels2G by lazy {
|
||||
baseOptions + (1..14).map { ChannelOption(it, SoftApConfigurationCompat.BAND_2GHZ) }
|
||||
}
|
||||
private val channels2G = (1..14).map { ChannelOption(SoftApConfigurationCompat.BAND_2GHZ, it) }
|
||||
private val channels5G by lazy {
|
||||
baseOptions + (1..196).map { ChannelOption(it, SoftApConfigurationCompat.BAND_5GHZ) }
|
||||
}
|
||||
@get:RequiresApi(30)
|
||||
private val channels6G by lazy {
|
||||
baseOptions + (1..233).map { ChannelOption(it, SoftApConfigurationCompat.BAND_6GHZ) }
|
||||
}
|
||||
@get:RequiresApi(31)
|
||||
private val channels60G by lazy {
|
||||
baseOptions + (1..6).map { ChannelOption(it, SoftApConfigurationCompat.BAND_60GHZ) }
|
||||
channels2G + (1..196).map { ChannelOption(SoftApConfigurationCompat.BAND_5GHZ, it) }
|
||||
}
|
||||
|
||||
private fun genAutoOptions(band: Int) = (1..band).filter { it and band == it }.map { ChannelOption(it) }
|
||||
/**
|
||||
* Source: https://android.googlesource.com/platform/frameworks/opt/net/wifi/+/c2fc6a1/service/java/com/android/server/wifi/p2p/SupplicantP2pIfaceHal.java#1396
|
||||
*/
|
||||
private val p2pChannels by lazy {
|
||||
baseOptions + (15..165).map { ChannelOption(it, SoftApConfigurationCompat.BAND_5GHZ) }
|
||||
private val p2pUnsafeOptions by lazy {
|
||||
listOf(ChannelOption(SoftApConfigurationCompat.BAND_LEGACY)) +
|
||||
channels2G + (15..165).map { ChannelOption(SoftApConfigurationCompat.BAND_5GHZ, it) }
|
||||
}
|
||||
private val p2pSafeOptions by lazy { genAutoOptions(SoftApConfigurationCompat.BAND_LEGACY) + channels5G }
|
||||
private val softApOptions by lazy {
|
||||
if (Build.VERSION.SDK_INT >= 30) {
|
||||
genAutoOptions(SoftApConfigurationCompat.BAND_ANY_31) +
|
||||
channels5G +
|
||||
(1..253).map { ChannelOption(SoftApConfigurationCompat.BAND_6GHZ, it) } +
|
||||
(1..6).map { ChannelOption(SoftApConfigurationCompat.BAND_60GHZ, it) }
|
||||
} else p2pSafeOptions
|
||||
}
|
||||
|
||||
@get:RequiresApi(30)
|
||||
private val bandWidthOptions by lazy {
|
||||
SoftApInfo.channelWidthLookup.lookup.let { lookup ->
|
||||
Array(lookup.size()) { BandWidth(lookup.keyAt(it), lookup.valueAt(it).substring(14)) }.apply { sort() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,62 +93,99 @@ class WifiApDialogFragment : AlertDialogFragment<WifiApDialogFragment.Arg, WifiA
|
||||
*/
|
||||
val p2pMode: Boolean = false) : Parcelable
|
||||
|
||||
private open class ChannelOption(val channel: Int = 0, private val band: Int = 0) {
|
||||
private open class ChannelOption(val band: Int = 0, val channel: Int = 0) {
|
||||
object Disabled : ChannelOption(-1) {
|
||||
override fun toString() = app.getString(R.string.wifi_ap_choose_disabled)
|
||||
}
|
||||
object Auto : ChannelOption() {
|
||||
override fun toString() = app.getString(R.string.wifi_ap_choose_auto)
|
||||
override fun toString() = if (channel == 0) {
|
||||
val format = DecimalFormat("#.#", DecimalFormatSymbols.getInstance(app.resources.configuration.locales[0]))
|
||||
app.getString(R.string.wifi_ap_choose_G, arrayOf(
|
||||
SoftApConfigurationCompat.BAND_2GHZ to 2.4,
|
||||
SoftApConfigurationCompat.BAND_5GHZ to 5,
|
||||
SoftApConfigurationCompat.BAND_6GHZ to 6,
|
||||
SoftApConfigurationCompat.BAND_60GHZ to 60,
|
||||
).filter { (mask, _) -> band and mask == mask }.joinToString("/") { (_, name) -> format.format(name) })
|
||||
} else "${SoftApConfigurationCompat.channelToFrequency(band, channel)} MHz ($channel)"
|
||||
}
|
||||
override fun toString() = "${SoftApConfigurationCompat.channelToFrequency(band, channel)} MHz ($channel)"
|
||||
|
||||
private class BandWidth(val width: Int, val name: String = "") : Comparable<BandWidth> {
|
||||
override fun compareTo(other: BandWidth) = width - other.width
|
||||
override fun toString() = name
|
||||
}
|
||||
|
||||
private lateinit var dialogView: DialogWifiApBinding
|
||||
private lateinit var base: SoftApConfigurationCompat
|
||||
private var pasted = false
|
||||
private var started = false
|
||||
private val currentChannels5G get() = if (arg.p2pMode && !RepeaterService.safeMode) p2pChannels else channels5G
|
||||
private val currentChannels get() = when {
|
||||
!arg.p2pMode -> softApOptions
|
||||
RepeaterService.safeMode -> p2pSafeOptions
|
||||
else -> p2pUnsafeOptions
|
||||
}
|
||||
private val acsList by lazy {
|
||||
listOf(
|
||||
Triple(SoftApConfigurationCompat.BAND_2GHZ, dialogView.acs2g, dialogView.acs2gWrapper),
|
||||
Triple(SoftApConfigurationCompat.BAND_5GHZ, dialogView.acs5g, dialogView.acs5gWrapper),
|
||||
Triple(SoftApConfigurationCompat.BAND_6GHZ, dialogView.acs6g, dialogView.acs6gWrapper),
|
||||
)
|
||||
}
|
||||
override val ret get() = Arg(generateConfig())
|
||||
private val hexToggleable get() = if (arg.p2pMode) !RepeaterService.safeMode else Build.VERSION.SDK_INT >= 33
|
||||
private var hexSsid = false
|
||||
set(value) {
|
||||
field = value
|
||||
dialogView.ssidWrapper.setEndIconActivated(value)
|
||||
}
|
||||
private val ssid get() =
|
||||
if (hexSsid) WifiSsidCompat.fromHex(dialogView.ssid.text) else WifiSsidCompat.fromUtf8Text(dialogView.ssid.text)
|
||||
|
||||
private fun generateChannels() = SparseIntArray(2).apply {
|
||||
if (!arg.p2pMode && Build.VERSION.SDK_INT >= 31) {
|
||||
(dialogView.bandSecondary.selectedItem as ChannelOption?)?.apply { if (band >= 0) put(band, channel) }
|
||||
}
|
||||
(dialogView.bandPrimary.selectedItem as ChannelOption).apply { put(band, channel) }
|
||||
}
|
||||
private fun generateConfig(full: Boolean = true) = base.copy(
|
||||
ssid = dialogView.ssid.text.toString(),
|
||||
ssid = ssid,
|
||||
passphrase = if (dialogView.password.length() != 0) dialogView.password.text.toString() else null).apply {
|
||||
if (!arg.p2pMode) {
|
||||
securityType = dialogView.security.selectedItemPosition
|
||||
isHiddenSsid = dialogView.hiddenSsid.isChecked
|
||||
}
|
||||
if (full) @TargetApi(28) {
|
||||
if (full) {
|
||||
isAutoShutdownEnabled = dialogView.autoShutdown.isChecked
|
||||
shutdownTimeoutMillis = dialogView.timeout.text.let { text ->
|
||||
if (text.isNullOrEmpty()) 0 else text.toString().toLong()
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= 23 || arg.p2pMode) {
|
||||
val channels = SparseIntArray(4)
|
||||
for ((band, spinner) in arrayOf(SoftApConfigurationCompat.BAND_2GHZ to dialogView.band2G,
|
||||
SoftApConfigurationCompat.BAND_5GHZ to dialogView.band5G,
|
||||
SoftApConfigurationCompat.BAND_6GHZ to dialogView.band6G,
|
||||
SoftApConfigurationCompat.BAND_60GHZ to dialogView.band60G)) {
|
||||
val channel = (spinner.selectedItem as ChannelOption?)?.channel
|
||||
if (channel != null && channel >= 0) channels.put(band, channel)
|
||||
}
|
||||
if (!arg.p2pMode && BuildCompat.isAtLeastS() && dialogView.bridgedMode.isChecked) {
|
||||
this.channels = channels
|
||||
} else optimizeChannels(channels)
|
||||
}
|
||||
bssid = if (dialogView.bssid.length() != 0) {
|
||||
MacAddressCompat.fromString(dialogView.bssid.text.toString())
|
||||
} else null
|
||||
channels = generateChannels()
|
||||
maxNumberOfClients = dialogView.maxClient.text.let { text ->
|
||||
if (text.isNullOrEmpty()) 0 else text.toString().toInt()
|
||||
}
|
||||
isClientControlByUserEnabled = dialogView.clientUserControl.isChecked
|
||||
allowedClientList = (dialogView.allowedList.text ?: "").split(nonMacChars)
|
||||
.filter { it.isNotEmpty() }.map { MacAddressCompat.fromString(it).toPlatform() }
|
||||
.filter { it.isNotEmpty() }.map(MacAddress::fromString)
|
||||
blockedClientList = (dialogView.blockedList.text ?: "").split(nonMacChars)
|
||||
.filter { it.isNotEmpty() }.map { MacAddressCompat.fromString(it).toPlatform() }
|
||||
setMacRandomizationEnabled(dialogView.macRandomization.isChecked)
|
||||
.filter { it.isNotEmpty() }.map(MacAddress::fromString)
|
||||
macRandomizationSetting = dialogView.macRandomization.selectedItemPosition
|
||||
bssid = if ((arg.p2pMode || Build.VERSION.SDK_INT < 31 && macRandomizationSetting ==
|
||||
SoftApConfigurationCompat.RANDOMIZATION_NONE) && dialogView.bssid.length() != 0) {
|
||||
MacAddress.fromString(dialogView.bssid.text.toString())
|
||||
} else null
|
||||
isBridgedModeOpportunisticShutdownEnabled = dialogView.bridgedModeOpportunisticShutdown.isChecked
|
||||
isIeee80211axEnabled = dialogView.ieee80211ax.isChecked
|
||||
isIeee80211beEnabled = dialogView.ieee80211be.isChecked
|
||||
isUserConfiguration = dialogView.userConfig.isChecked
|
||||
bridgedModeOpportunisticShutdownTimeoutMillis = dialogView.bridgedTimeout.text.let { text ->
|
||||
if (text.isNullOrEmpty()) -1L else text.toString().toLong()
|
||||
}
|
||||
vendorElements = VendorElements.deserialize(dialogView.vendorElements.text)
|
||||
persistentRandomizedMacAddress = if (dialogView.persistentRandomizedMac.length() != 0) {
|
||||
MacAddress.fromString(dialogView.persistentRandomizedMac.text.toString())
|
||||
} else null
|
||||
allowedAcsChannels = acsList.associate { (band, text, _) -> band to RangeInput.fromString(text.text) }
|
||||
if (!arg.p2pMode && Build.VERSION.SDK_INT >= 33) {
|
||||
maxChannelBandwidth = (dialogView.maxChannelBandwidth.selectedItem as BandWidth).width
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -148,6 +198,31 @@ class WifiApDialogFragment : AlertDialogFragment<WifiApDialogFragment.Arg, WifiA
|
||||
setNegativeButton(R.string.donations__button_close, null)
|
||||
dialogView.toolbar.inflateMenu(R.menu.toolbar_configuration)
|
||||
dialogView.toolbar.setOnMenuItemClickListener(this@WifiApDialogFragment)
|
||||
dialogView.ssidWrapper.setLengthCounter {
|
||||
try {
|
||||
ssid?.bytes?.size ?: 0
|
||||
} catch (_: IllegalArgumentException) {
|
||||
0
|
||||
}
|
||||
}
|
||||
if (hexToggleable) dialogView.ssidWrapper.apply {
|
||||
endIconMode = TextInputLayout.END_ICON_CUSTOM
|
||||
setEndIconOnClickListener {
|
||||
val ssid = try {
|
||||
ssid
|
||||
} catch (_: IllegalArgumentException) {
|
||||
return@setEndIconOnClickListener
|
||||
}
|
||||
val newText = if (hexSsid) ssid?.run {
|
||||
decode().also { if (it == null) return@setEndIconOnClickListener }
|
||||
} else ssid?.hex
|
||||
hexSsid = !hexSsid
|
||||
dialogView.ssid.setText(newText)
|
||||
}
|
||||
findViewById<View>(com.google.android.material.R.id.text_input_end_icon).apply {
|
||||
tooltipText = contentDescription
|
||||
}
|
||||
}
|
||||
if (!arg.readOnly) dialogView.ssid.addTextChangedListener(this@WifiApDialogFragment)
|
||||
if (arg.p2pMode) dialogView.securityWrapper.isGone = true else dialogView.security.apply {
|
||||
adapter = ArrayAdapter(activity, android.R.layout.simple_spinner_item, 0,
|
||||
@@ -157,99 +232,115 @@ class WifiApDialogFragment : AlertDialogFragment<WifiApDialogFragment.Arg, WifiA
|
||||
onItemSelectedListener = object : AdapterView.OnItemSelectedListener {
|
||||
override fun onNothingSelected(parent: AdapterView<*>?) = error("Must select something")
|
||||
override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) {
|
||||
dialogView.passwordWrapper.isGone = position == SoftApConfiguration.SECURITY_TYPE_OPEN
|
||||
when (position) {
|
||||
SoftApConfiguration.SECURITY_TYPE_OPEN,
|
||||
SoftApConfiguration.SECURITY_TYPE_WPA3_OWE_TRANSITION,
|
||||
SoftApConfiguration.SECURITY_TYPE_WPA3_OWE -> dialogView.passwordWrapper.isGone = true
|
||||
else -> {
|
||||
dialogView.passwordWrapper.isGone = false
|
||||
if (position == SoftApConfiguration.SECURITY_TYPE_WPA3_SAE) {
|
||||
dialogView.passwordWrapper.isCounterEnabled = false
|
||||
dialogView.passwordWrapper.counterMaxLength = 0
|
||||
dialogView.password.filters = emptyArray()
|
||||
} else {
|
||||
dialogView.passwordWrapper.isCounterEnabled = true
|
||||
dialogView.passwordWrapper.counterMaxLength = 63
|
||||
dialogView.password.filters = arrayOf(InputFilter.LengthFilter(63))
|
||||
}
|
||||
}
|
||||
}
|
||||
validate()
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!arg.readOnly) dialogView.password.addTextChangedListener(this@WifiApDialogFragment)
|
||||
if (!arg.p2pMode && Build.VERSION.SDK_INT < 28) dialogView.autoShutdown.isGone = true
|
||||
if (arg.p2pMode || Build.VERSION.SDK_INT >= 30) {
|
||||
dialogView.timeoutWrapper.helperText = getString(R.string.wifi_hotspot_timeout_default,
|
||||
TetherTimeoutMonitor.defaultTimeout)
|
||||
dialogView.timeout.addTextChangedListener(this@WifiApDialogFragment)
|
||||
if (!arg.readOnly) dialogView.timeout.addTextChangedListener(this@WifiApDialogFragment)
|
||||
} else dialogView.timeoutWrapper.isGone = true
|
||||
fun Spinner.configure(options: List<ChannelOption>) {
|
||||
adapter = ArrayAdapter(activity, android.R.layout.simple_spinner_item, 0, options).apply {
|
||||
setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item)
|
||||
}
|
||||
onItemSelectedListener = this@WifiApDialogFragment
|
||||
if (!arg.readOnly) onItemSelectedListener = this@WifiApDialogFragment
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= 23 || arg.p2pMode) {
|
||||
dialogView.band2G.configure(channels2G)
|
||||
dialogView.band5G.configure(currentChannels5G)
|
||||
} else {
|
||||
dialogView.bandWrapper2G.isGone = true
|
||||
dialogView.bandWrapper5G.isGone = true
|
||||
}
|
||||
if (Build.VERSION.SDK_INT >= 30 && !arg.p2pMode) dialogView.band6G.configure(channels6G)
|
||||
else dialogView.bandWrapper6G.isGone = true
|
||||
if (BuildCompat.isAtLeastS() && !arg.p2pMode) dialogView.band60G.configure(channels60G)
|
||||
else dialogView.bandWrapper60G.isGone = true
|
||||
dialogView.bssid.addTextChangedListener(this@WifiApDialogFragment)
|
||||
if (arg.p2pMode) dialogView.hiddenSsid.isGone = true
|
||||
if (arg.p2pMode || Build.VERSION.SDK_INT < 30) {
|
||||
dialogView.maxClientWrapper.isGone = true
|
||||
dialogView.clientUserControl.isGone = true
|
||||
dialogView.blockedListWrapper.isGone = true
|
||||
dialogView.allowedListWrapper.isGone = true
|
||||
} else {
|
||||
dialogView.bandPrimary.configure(currentChannels)
|
||||
if (Build.VERSION.SDK_INT >= 31 && !arg.p2pMode) {
|
||||
dialogView.bandSecondary.configure(listOf(ChannelOption.Disabled) + currentChannels)
|
||||
} else dialogView.bandSecondary.isGone = true
|
||||
if (arg.p2pMode || Build.VERSION.SDK_INT < 30) dialogView.accessControlGroup.isGone = true
|
||||
else if (!arg.readOnly) {
|
||||
dialogView.maxClient.addTextChangedListener(this@WifiApDialogFragment)
|
||||
dialogView.blockedList.addTextChangedListener(this@WifiApDialogFragment)
|
||||
dialogView.allowedList.addTextChangedListener(this@WifiApDialogFragment)
|
||||
}
|
||||
if (!arg.readOnly) dialogView.bssid.addTextChangedListener(this@WifiApDialogFragment)
|
||||
if (arg.p2pMode) dialogView.hiddenSsid.isGone = true
|
||||
if (arg.p2pMode && Build.VERSION.SDK_INT >= 29) dialogView.macRandomization.isEnabled = false
|
||||
else if (arg.p2pMode || !BuildCompat.isAtLeastS()) dialogView.macRandomization.isGone = true
|
||||
if (arg.p2pMode || !BuildCompat.isAtLeastS()) {
|
||||
dialogView.bridgedMode.isGone = true
|
||||
dialogView.bridgedModeOpportunisticShutdown.isGone = true
|
||||
else if (arg.p2pMode || Build.VERSION.SDK_INT < 31) dialogView.macRandomizationWrapper.isGone = true
|
||||
else dialogView.macRandomization.onItemSelectedListener = this@WifiApDialogFragment
|
||||
if (arg.p2pMode || Build.VERSION.SDK_INT < 31) {
|
||||
dialogView.ieee80211ax.isGone = true
|
||||
dialogView.bridgedModeOpportunisticShutdown.isGone = true
|
||||
dialogView.userConfig.isGone = true
|
||||
dialogView.bridgedTimeoutWrapper.isGone = true
|
||||
} else {
|
||||
dialogView.bridgedTimeoutWrapper.helperText = getString(R.string.wifi_hotspot_timeout_default,
|
||||
TetherTimeoutMonitor.defaultTimeoutBridged)
|
||||
}
|
||||
if (Build.VERSION.SDK_INT < 33) dialogView.vendorElementsWrapper.isGone = true
|
||||
else if (!arg.readOnly) dialogView.vendorElements.addTextChangedListener(this@WifiApDialogFragment)
|
||||
if (arg.p2pMode || Build.VERSION.SDK_INT < 33) {
|
||||
dialogView.ieee80211be.isGone = true
|
||||
dialogView.bridgedTimeout.isEnabled = false
|
||||
dialogView.persistentRandomizedMacWrapper.isGone = true
|
||||
for ((_, _, wrapper) in acsList) wrapper.isGone = true
|
||||
dialogView.maxChannelBandwidthWrapper.isGone = true
|
||||
} else {
|
||||
dialogView.maxChannelBandwidth.adapter = ArrayAdapter(activity, android.R.layout.simple_spinner_item, 0,
|
||||
bandWidthOptions).apply {
|
||||
setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item)
|
||||
}
|
||||
if (!arg.readOnly) {
|
||||
dialogView.bridgedTimeout.addTextChangedListener(this@WifiApDialogFragment)
|
||||
dialogView.persistentRandomizedMac.addTextChangedListener(this@WifiApDialogFragment)
|
||||
for ((_, text, _) in acsList) text.addTextChangedListener(this@WifiApDialogFragment)
|
||||
dialogView.acs5g.addTextChangedListener(this@WifiApDialogFragment)
|
||||
dialogView.acs6g.addTextChangedListener(this@WifiApDialogFragment)
|
||||
dialogView.maxChannelBandwidth.onItemSelectedListener = this@WifiApDialogFragment
|
||||
}
|
||||
}
|
||||
base = arg.configuration
|
||||
populateFromConfiguration()
|
||||
}
|
||||
|
||||
private fun locate(band: Int, channels: List<ChannelOption>): Int {
|
||||
val channel = base.getChannel(band)
|
||||
val selection = channels.indexOfFirst { it.channel == channel }
|
||||
private fun locate(i: Int): Int {
|
||||
val band = base.channels.keyAt(i)
|
||||
val channel = base.channels.valueAt(i)
|
||||
val selection = currentChannels.indexOfFirst { it.band == band && it.channel == channel }
|
||||
return if (selection == -1) {
|
||||
Timber.w(Exception("Unable to locate $band, $channel, ${arg.p2pMode && !RepeaterService.safeMode}"))
|
||||
val msg = "Unable to locate $band, $channel, ${arg.p2pMode && !RepeaterService.safeMode}"
|
||||
if (pasted || arg.p2pMode) Timber.w(msg) else Timber.w(Exception(msg))
|
||||
0
|
||||
} else selection
|
||||
}
|
||||
private var userBridgedMode = false
|
||||
private fun setBridgedMode() {
|
||||
var auto = 0
|
||||
var set = 0
|
||||
for (s in arrayOf(dialogView.band2G, dialogView.band5G, dialogView.band6G)) when (s.selectedItem) {
|
||||
is ChannelOption.Auto -> auto = 1
|
||||
!is ChannelOption.Disabled -> ++set
|
||||
}
|
||||
if (auto + set > 1) {
|
||||
if (dialogView.bridgedMode.isEnabled) {
|
||||
userBridgedMode = dialogView.bridgedMode.isChecked
|
||||
dialogView.bridgedMode.isEnabled = false
|
||||
dialogView.bridgedMode.isChecked = true
|
||||
}
|
||||
} else if (!dialogView.bridgedMode.isEnabled) {
|
||||
dialogView.bridgedMode.isEnabled = true
|
||||
dialogView.bridgedMode.isChecked = userBridgedMode
|
||||
}
|
||||
}
|
||||
private fun populateFromConfiguration() {
|
||||
dialogView.ssid.setText(base.ssid)
|
||||
dialogView.ssid.setText(base.ssid.let { ssid ->
|
||||
when {
|
||||
ssid == null -> null
|
||||
hexSsid -> ssid.hex
|
||||
hexToggleable -> ssid.decode() ?: ssid.hex.also { hexSsid = true }
|
||||
else -> ssid.toString()
|
||||
}
|
||||
})
|
||||
if (!arg.p2pMode) dialogView.security.setSelection(base.securityType)
|
||||
dialogView.password.setText(base.passphrase)
|
||||
dialogView.autoShutdown.isChecked = base.isAutoShutdownEnabled
|
||||
dialogView.timeout.setText(base.shutdownTimeoutMillis.let { if (it == 0L) "" else it.toString() })
|
||||
if (Build.VERSION.SDK_INT >= 23 || arg.p2pMode) {
|
||||
dialogView.band2G.setSelection(locate(SoftApConfigurationCompat.BAND_2GHZ, channels2G))
|
||||
dialogView.band5G.setSelection(locate(SoftApConfigurationCompat.BAND_5GHZ, currentChannels5G))
|
||||
dialogView.band6G.setSelection(locate(SoftApConfigurationCompat.BAND_6GHZ, channels6G))
|
||||
dialogView.band60G.setSelection(locate(SoftApConfigurationCompat.BAND_60GHZ, channels60G))
|
||||
userBridgedMode = base.channels.size() > 1
|
||||
dialogView.bridgedMode.isChecked = userBridgedMode
|
||||
setBridgedMode()
|
||||
dialogView.timeout.setText(base.shutdownTimeoutMillis.let { if (it <= 0) "" else it.toString() })
|
||||
dialogView.bandPrimary.setSelection(locate(0))
|
||||
if (Build.VERSION.SDK_INT >= 31 && !arg.p2pMode) {
|
||||
dialogView.bandSecondary.setSelection(if (base.channels.size() > 1) locate(1) + 1 else 0)
|
||||
}
|
||||
dialogView.bssid.setText(base.bssid?.toString())
|
||||
dialogView.hiddenSsid.isChecked = base.isHiddenSsid
|
||||
@@ -257,11 +348,22 @@ class WifiApDialogFragment : AlertDialogFragment<WifiApDialogFragment.Arg, WifiA
|
||||
dialogView.clientUserControl.isChecked = base.isClientControlByUserEnabled
|
||||
dialogView.blockedList.setText(base.blockedClientList.joinToString("\n"))
|
||||
dialogView.allowedList.setText(base.allowedClientList.joinToString("\n"))
|
||||
dialogView.macRandomization.isChecked =
|
||||
base.macRandomizationSetting == SoftApConfigurationCompat.RANDOMIZATION_PERSISTENT
|
||||
dialogView.macRandomization.setSelection(base.macRandomizationSetting)
|
||||
dialogView.bridgedModeOpportunisticShutdown.isChecked = base.isBridgedModeOpportunisticShutdownEnabled
|
||||
dialogView.ieee80211ax.isChecked = base.isIeee80211axEnabled
|
||||
dialogView.ieee80211be.isChecked = base.isIeee80211beEnabled
|
||||
dialogView.userConfig.isChecked = base.isUserConfiguration
|
||||
dialogView.bridgedTimeout.setText(base.bridgedModeOpportunisticShutdownTimeoutMillis.let {
|
||||
if (it == -1L) "" else it.toString()
|
||||
})
|
||||
dialogView.vendorElements.setText(VendorElements.serialize(base.vendorElements))
|
||||
dialogView.persistentRandomizedMac.setText(base.persistentRandomizedMacAddress?.toString())
|
||||
for ((band, text, _) in acsList) text.setText(RangeInput.toString(base.allowedAcsChannels[band]))
|
||||
if (Build.VERSION.SDK_INT >= 33) bandWidthOptions.binarySearch(BandWidth(base.maxChannelBandwidth)).let {
|
||||
if (it < 0) {
|
||||
Timber.w(Exception("Cannot locate bandwidth ${base.maxChannelBandwidth}"))
|
||||
} else dialogView.maxChannelBandwidth.setSelection(it)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onStart() {
|
||||
@@ -270,64 +372,62 @@ class WifiApDialogFragment : AlertDialogFragment<WifiApDialogFragment.Arg, WifiA
|
||||
validate()
|
||||
}
|
||||
|
||||
@TargetApi(28)
|
||||
private fun validate() {
|
||||
if (!started) return
|
||||
val ssidLength = dialogView.ssid.text.toString().toByteArray().size
|
||||
dialogView.ssidWrapper.error = if (arg.p2pMode && RepeaterService.safeMode && ssidLength < 9) {
|
||||
val (ssidOk, ssidError) = 0.let {
|
||||
val ssid = try {
|
||||
ssid
|
||||
} catch (e: IllegalArgumentException) {
|
||||
return@let false to e.readableMessage
|
||||
}
|
||||
val ssidLength = ssid?.bytes?.size ?: 0
|
||||
if (ssidLength in 1..32) true to if (arg.p2pMode && RepeaterService.safeMode && ssidLength < 9) {
|
||||
requireContext().getString(R.string.settings_service_repeater_safe_mode_warning)
|
||||
} else null
|
||||
} else null else false to " "
|
||||
}
|
||||
dialogView.ssidWrapper.error = ssidError
|
||||
val selectedSecurity = if (arg.p2pMode) {
|
||||
SoftApConfiguration.SECURITY_TYPE_WPA2_PSK
|
||||
} else dialogView.security.selectedItemPosition
|
||||
// see also: https://android.googlesource.com/platform/frameworks/base/+/92c8f59/wifi/java/android/net/wifi/SoftApConfiguration.java#688
|
||||
val passwordValid = when (selectedSecurity) {
|
||||
SoftApConfiguration.SECURITY_TYPE_OPEN,
|
||||
SoftApConfiguration.SECURITY_TYPE_WPA3_OWE_TRANSITION,
|
||||
SoftApConfiguration.SECURITY_TYPE_WPA3_OWE -> true
|
||||
SoftApConfiguration.SECURITY_TYPE_WPA2_PSK, SoftApConfiguration.SECURITY_TYPE_WPA3_SAE_TRANSITION -> {
|
||||
dialogView.password.length() >= 8
|
||||
dialogView.password.length() in 8..63
|
||||
}
|
||||
else -> true // do not try to validate
|
||||
else -> dialogView.password.length() > 0
|
||||
}
|
||||
dialogView.passwordWrapper.error = if (passwordValid) null else " "
|
||||
val timeoutError = dialogView.timeout.text.let { text ->
|
||||
if (text.isNullOrEmpty()) null else try {
|
||||
text.toString().toLong()
|
||||
SoftApConfigurationCompat.testPlatformTimeoutValidity(text.toString().toLong())
|
||||
null
|
||||
} catch (e: NumberFormatException) {
|
||||
} catch (e: Exception) {
|
||||
e.readableMessage
|
||||
}
|
||||
}
|
||||
dialogView.timeoutWrapper.error = timeoutError
|
||||
val isBandValid = when {
|
||||
arg.p2pMode || Build.VERSION.SDK_INT in 23 until 30 -> {
|
||||
val option5G = dialogView.band5G.selectedItem
|
||||
when (dialogView.band2G.selectedItem) {
|
||||
is ChannelOption.Disabled -> option5G !is ChannelOption.Disabled &&
|
||||
(!arg.p2pMode || RepeaterService.safeMode || option5G !is ChannelOption.Auto)
|
||||
is ChannelOption.Auto ->
|
||||
(arg.p2pMode || Build.VERSION.SDK_INT >= 28) && option5G is ChannelOption.Auto ||
|
||||
(!arg.p2pMode || RepeaterService.safeMode) && option5G is ChannelOption.Disabled
|
||||
else -> option5G is ChannelOption.Disabled
|
||||
}
|
||||
}
|
||||
Build.VERSION.SDK_INT == 30 && !BuildCompat.isAtLeastS() -> {
|
||||
var expected = 1
|
||||
var set = 0
|
||||
for (s in arrayOf(dialogView.band2G, dialogView.band5G, dialogView.band6G)) when (s.selectedItem) {
|
||||
is ChannelOption.Auto -> expected = 0
|
||||
!is ChannelOption.Disabled -> ++set
|
||||
}
|
||||
set == expected
|
||||
}
|
||||
else -> {
|
||||
setBridgedMode()
|
||||
true
|
||||
}
|
||||
val bandError = if (!arg.p2pMode && Build.VERSION.SDK_INT >= 30) {
|
||||
try {
|
||||
SoftApConfigurationCompat.testPlatformValidity(generateChannels())
|
||||
null
|
||||
} catch (e: Exception) {
|
||||
e.readableMessage
|
||||
}
|
||||
} else null
|
||||
dialogView.bandError.isGone = bandError.isNullOrEmpty()
|
||||
dialogView.bandError.text = bandError
|
||||
val hideBssid = !arg.p2pMode && Build.VERSION.SDK_INT >= 31 &&
|
||||
dialogView.macRandomization.selectedItemPosition != SoftApConfigurationCompat.RANDOMIZATION_NONE
|
||||
dialogView.bssidWrapper.isGone = hideBssid
|
||||
dialogView.bssidWrapper.error = null
|
||||
val bssidValid = dialogView.bssid.length() == 0 || try {
|
||||
MacAddressCompat.fromString(dialogView.bssid.text.toString())
|
||||
val bssidValid = hideBssid || dialogView.bssid.length() == 0 || try {
|
||||
val mac = MacAddress.fromString(dialogView.bssid.text.toString())
|
||||
if (Build.VERSION.SDK_INT >= 30 && !arg.p2pMode) SoftApConfigurationCompat.testPlatformValidity(mac)
|
||||
true
|
||||
} catch (e: IllegalArgumentException) {
|
||||
} catch (e: Exception) {
|
||||
dialogView.bssidWrapper.error = e.readableMessage
|
||||
false
|
||||
}
|
||||
@@ -340,26 +440,80 @@ class WifiApDialogFragment : AlertDialogFragment<WifiApDialogFragment.Arg, WifiA
|
||||
}
|
||||
}
|
||||
dialogView.maxClientWrapper.error = maxClientError
|
||||
val blockedListError = try {
|
||||
(dialogView.blockedList.text ?: "").split(nonMacChars)
|
||||
.filter { it.isNotEmpty() }.forEach { MacAddressCompat.fromString(it).toPlatform() }
|
||||
null
|
||||
val listsNoError = if (Build.VERSION.SDK_INT >= 30) {
|
||||
val (blockedList, blockedListError) = try {
|
||||
(dialogView.blockedList.text ?: "").split(nonMacChars).filter { it.isNotEmpty() }
|
||||
.map(MacAddress::fromString).toSet() to null
|
||||
} catch (e: IllegalArgumentException) {
|
||||
e.readableMessage
|
||||
null to e.readableMessage
|
||||
}
|
||||
dialogView.blockedListWrapper.error = blockedListError
|
||||
val allowedListError = try {
|
||||
(dialogView.allowedList.text ?: "").split(nonMacChars)
|
||||
.filter { it.isNotEmpty() }.forEach { MacAddressCompat.fromString(it).toPlatform() }
|
||||
(dialogView.allowedList.text ?: "").split(nonMacChars).filter { it.isNotEmpty() }.forEach {
|
||||
val mac = MacAddress.fromString(it)
|
||||
require(blockedList?.contains(mac) != true) { "A MAC address exists in both client lists" }
|
||||
}
|
||||
null
|
||||
} catch (e: IllegalArgumentException) {
|
||||
e.readableMessage
|
||||
}
|
||||
dialogView.allowedListWrapper.error = allowedListError
|
||||
val canCopy = timeoutError == null && bssidValid && maxClientError == null && blockedListError == null &&
|
||||
allowedListError == null
|
||||
blockedListError == null && allowedListError == null
|
||||
} else true
|
||||
val bridgedTimeoutError = dialogView.bridgedTimeout.text.let { text ->
|
||||
if (text.isNullOrEmpty()) null else try {
|
||||
SoftApConfigurationCompat.testPlatformBridgedTimeoutValidity(text.toString().toLong())
|
||||
null
|
||||
} catch (e: Exception) {
|
||||
e.readableMessage
|
||||
}
|
||||
}
|
||||
dialogView.bridgedTimeoutWrapper.error = bridgedTimeoutError
|
||||
val vendorElementsError = if (Build.VERSION.SDK_INT >= 33) {
|
||||
try {
|
||||
VendorElements.deserialize(dialogView.vendorElements.text).also {
|
||||
if (!arg.p2pMode) SoftApConfigurationCompat.testPlatformValidity(it)
|
||||
}
|
||||
null
|
||||
} catch (e: Exception) {
|
||||
e.readableMessage
|
||||
}
|
||||
} else null
|
||||
dialogView.vendorElementsWrapper.error = vendorElementsError
|
||||
dialogView.persistentRandomizedMacWrapper.error = null
|
||||
val persistentRandomizedMacValid = dialogView.persistentRandomizedMac.length() == 0 || try {
|
||||
MacAddress.fromString(dialogView.persistentRandomizedMac.text.toString())
|
||||
true
|
||||
} catch (e: IllegalArgumentException) {
|
||||
dialogView.persistentRandomizedMacWrapper.error = e.readableMessage
|
||||
false
|
||||
}
|
||||
val acsNoError = if (!arg.p2pMode && Build.VERSION.SDK_INT >= 33) acsList.all { (band, text, wrapper) ->
|
||||
try {
|
||||
wrapper.error = null
|
||||
SoftApConfigurationCompat.testPlatformValidity(band, RangeInput.fromString(text.text).toIntArray())
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
wrapper.error = e.readableMessage
|
||||
false
|
||||
}
|
||||
} else true
|
||||
val bandwidthError = if (!arg.p2pMode && Build.VERSION.SDK_INT >= 33) {
|
||||
try {
|
||||
SoftApConfigurationCompat.testPlatformValidity(
|
||||
(dialogView.maxChannelBandwidth.selectedItem as BandWidth).width)
|
||||
null
|
||||
} catch (e: Exception) {
|
||||
e.readableMessage
|
||||
}
|
||||
} else null
|
||||
dialogView.maxChannelBandwidthError.isGone = bandwidthError.isNullOrEmpty()
|
||||
dialogView.maxChannelBandwidthError.text = bandwidthError
|
||||
val canCopy = timeoutError == null && bssidValid && maxClientError == null && listsNoError &&
|
||||
bridgedTimeoutError == null && vendorElementsError == null && persistentRandomizedMacValid &&
|
||||
acsNoError && bandwidthError == null
|
||||
(dialog as? AlertDialog)?.getButton(DialogInterface.BUTTON_POSITIVE)?.isEnabled =
|
||||
ssidLength in 1..32 && passwordValid && isBandValid && canCopy
|
||||
ssidOk && passwordValid && bandError == null && canCopy
|
||||
dialogView.toolbar.menu.findItem(android.R.id.copy).isEnabled = canCopy
|
||||
}
|
||||
|
||||
@@ -372,10 +526,15 @@ class WifiApDialogFragment : AlertDialogFragment<WifiApDialogFragment.Arg, WifiA
|
||||
|
||||
override fun onMenuItemClick(item: MenuItem?): Boolean {
|
||||
return when (item?.itemId) {
|
||||
android.R.id.copy -> {
|
||||
android.R.id.copy -> try {
|
||||
app.clipboard.setPrimaryClip(ClipData.newPlainText(null,
|
||||
Base64.encodeToString(generateConfig().toByteArray(), BASE64_FLAGS)))
|
||||
Base64.encodeToString(generateConfig().toByteArray(), BASE64_FLAGS)).apply {
|
||||
description.extras = persistableBundleOf(ClipDescription.EXTRA_IS_SENSITIVE to true)
|
||||
})
|
||||
true
|
||||
} catch (e: RuntimeException) {
|
||||
Toast.makeText(context, e.readableMessage, Toast.LENGTH_LONG).show()
|
||||
false
|
||||
}
|
||||
android.R.id.paste -> try {
|
||||
app.clipboard.primaryClip?.getItemAt(0)?.text?.apply {
|
||||
@@ -385,12 +544,13 @@ class WifiApDialogFragment : AlertDialogFragment<WifiApDialogFragment.Arg, WifiA
|
||||
arg.configuration.underlying?.let { check(it.javaClass == newUnderlying.javaClass) }
|
||||
} else config.underlying = arg.configuration.underlying
|
||||
base = config
|
||||
pasted = true
|
||||
populateFromConfiguration()
|
||||
}
|
||||
}
|
||||
true
|
||||
} catch (e: IllegalArgumentException) {
|
||||
SmartSnackbar.make(e).show()
|
||||
} catch (e: RuntimeException) {
|
||||
Toast.makeText(context, e.readableMessage, Toast.LENGTH_LONG).show()
|
||||
false
|
||||
}
|
||||
R.id.share_qr -> {
|
||||
|
||||
@@ -10,13 +10,8 @@ import android.os.Build
|
||||
import android.os.Handler
|
||||
import android.os.Parcelable
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.core.os.BuildCompat
|
||||
import be.mygod.vpnhotspot.App.Companion.app
|
||||
import be.mygod.vpnhotspot.net.wifi.SoftApConfigurationCompat.Companion.toCompat
|
||||
import be.mygod.vpnhotspot.util.ConstantLookup
|
||||
import be.mygod.vpnhotspot.util.Services
|
||||
import be.mygod.vpnhotspot.util.callSuper
|
||||
import be.mygod.vpnhotspot.util.findIdentifier
|
||||
import be.mygod.vpnhotspot.util.*
|
||||
import timber.log.Timber
|
||||
import java.lang.reflect.InvocationHandler
|
||||
import java.lang.reflect.Method
|
||||
@@ -39,7 +34,8 @@ object WifiApManager {
|
||||
PackageManager.MATCH_SYSTEM_ONLY).single()
|
||||
|
||||
private const val CONFIG_P2P_MAC_RANDOMIZATION_SUPPORTED = "config_wifi_p2p_mac_randomization_supported"
|
||||
val p2pMacRandomizationSupported get() = when (Build.VERSION.SDK_INT) {
|
||||
val p2pMacRandomizationSupported get() = try {
|
||||
when (Build.VERSION.SDK_INT) {
|
||||
29 -> Resources.getSystem().run {
|
||||
getBoolean(getIdentifier(CONFIG_P2P_MAC_RANDOMIZATION_SUPPORTED, "bool", "android"))
|
||||
}
|
||||
@@ -51,6 +47,10 @@ object WifiApManager {
|
||||
}
|
||||
else -> false
|
||||
}
|
||||
} catch (e: RuntimeException) {
|
||||
Timber.w(e)
|
||||
false
|
||||
}
|
||||
|
||||
@get:RequiresApi(30)
|
||||
private val apMacRandomizationSupported by lazy {
|
||||
@@ -59,6 +59,92 @@ object WifiApManager {
|
||||
@get:RequiresApi(30)
|
||||
val isApMacRandomizationSupported get() = apMacRandomizationSupported(Services.wifi) as Boolean
|
||||
|
||||
/**
|
||||
* Broadcast intent action indicating that Wi-Fi AP has been enabled, disabled,
|
||||
* enabling, disabling, or failed.
|
||||
*/
|
||||
const val WIFI_AP_STATE_CHANGED_ACTION = "android.net.wifi.WIFI_AP_STATE_CHANGED"
|
||||
/**
|
||||
* The lookup key for an int that indicates whether Wi-Fi AP is enabled,
|
||||
* disabled, enabling, disabling, or failed. Retrieve it with [Intent.getIntExtra].
|
||||
*
|
||||
* @see WIFI_AP_STATE_DISABLED
|
||||
* @see WIFI_AP_STATE_DISABLING
|
||||
* @see WIFI_AP_STATE_ENABLED
|
||||
* @see WIFI_AP_STATE_ENABLING
|
||||
* @see WIFI_AP_STATE_FAILED
|
||||
*/
|
||||
private const val EXTRA_WIFI_AP_STATE = "wifi_state"
|
||||
/**
|
||||
* An extra containing the int error code for Soft AP start failure.
|
||||
* Can be obtained from the [WIFI_AP_STATE_CHANGED_ACTION] using [Intent.getIntExtra].
|
||||
* This extra will only be attached if [EXTRA_WIFI_AP_STATE] is
|
||||
* attached and is equal to [WIFI_AP_STATE_FAILED].
|
||||
*
|
||||
* The error code will be one of:
|
||||
* {@link #SAP_START_FAILURE_GENERAL},
|
||||
* {@link #SAP_START_FAILURE_NO_CHANNEL},
|
||||
* {@link #SAP_START_FAILURE_UNSUPPORTED_CONFIGURATION}
|
||||
*
|
||||
* Source: https://android.googlesource.com/platform/frameworks/base/+/android-6.0.0_r1/wifi/java/android/net/wifi/WifiManager.java#210
|
||||
*/
|
||||
val EXTRA_WIFI_AP_FAILURE_REASON get() =
|
||||
if (Build.VERSION.SDK_INT >= 30) "android.net.wifi.extra.WIFI_AP_FAILURE_REASON" else "wifi_ap_error_code"
|
||||
/**
|
||||
* The lookup key for a String extra that stores the interface name used for the Soft AP.
|
||||
* This extra is included in the broadcast [WIFI_AP_STATE_CHANGED_ACTION].
|
||||
* Retrieve its value with [Intent.getStringExtra].
|
||||
*
|
||||
* Source: https://android.googlesource.com/platform/frameworks/base/+/android-8.0.0_r1/wifi/java/android/net/wifi/WifiManager.java#413
|
||||
*/
|
||||
val EXTRA_WIFI_AP_INTERFACE_NAME get() =
|
||||
if (Build.VERSION.SDK_INT >= 30) "android.net.wifi.extra.WIFI_AP_INTERFACE_NAME" else "wifi_ap_interface_name"
|
||||
|
||||
fun checkWifiApState(state: Int) = if (state < WIFI_AP_STATE_DISABLING || state > WIFI_AP_STATE_FAILED) {
|
||||
Timber.w(Exception("Unknown state $state"))
|
||||
false
|
||||
} else true
|
||||
val Intent.wifiApState get() =
|
||||
getIntExtra(EXTRA_WIFI_AP_STATE, WIFI_AP_STATE_DISABLED).also { checkWifiApState(it) }
|
||||
/**
|
||||
* Wi-Fi AP is currently being disabled. The state will change to
|
||||
* [WIFI_AP_STATE_DISABLED] if it finishes successfully.
|
||||
*
|
||||
* @see WIFI_AP_STATE_CHANGED_ACTION
|
||||
* @see #getWifiApState()
|
||||
*/
|
||||
const val WIFI_AP_STATE_DISABLING = 10
|
||||
/**
|
||||
* Wi-Fi AP is disabled.
|
||||
*
|
||||
* @see WIFI_AP_STATE_CHANGED_ACTION
|
||||
* @see #getWifiState()
|
||||
*/
|
||||
const val WIFI_AP_STATE_DISABLED = 11
|
||||
/**
|
||||
* Wi-Fi AP is currently being enabled. The state will change to
|
||||
* {@link #WIFI_AP_STATE_ENABLED} if it finishes successfully.
|
||||
*
|
||||
* @see WIFI_AP_STATE_CHANGED_ACTION
|
||||
* @see #getWifiApState()
|
||||
*/
|
||||
const val WIFI_AP_STATE_ENABLING = 12
|
||||
/**
|
||||
* Wi-Fi AP is enabled.
|
||||
*
|
||||
* @see WIFI_AP_STATE_CHANGED_ACTION
|
||||
* @see #getWifiApState()
|
||||
*/
|
||||
const val WIFI_AP_STATE_ENABLED = 13
|
||||
/**
|
||||
* Wi-Fi AP is in a failed state. This state will occur when an error occurs during
|
||||
* enabling or disabling
|
||||
*
|
||||
* @see WIFI_AP_STATE_CHANGED_ACTION
|
||||
* @see #getWifiApState()
|
||||
*/
|
||||
const val WIFI_AP_STATE_FAILED = 14
|
||||
|
||||
private val getWifiApConfiguration by lazy { WifiManager::class.java.getDeclaredMethod("getWifiApConfiguration") }
|
||||
@Suppress("DEPRECATION")
|
||||
private val setWifiApConfiguration by lazy {
|
||||
@@ -72,29 +158,29 @@ object WifiApManager {
|
||||
WifiManager::class.java.getDeclaredMethod("setSoftApConfiguration", SoftApConfiguration::class.java)
|
||||
}
|
||||
|
||||
@get:RequiresApi(30)
|
||||
val configuration get() = getSoftApConfiguration(Services.wifi) as SoftApConfiguration
|
||||
/**
|
||||
* Requires NETWORK_SETTINGS permission (or root) on API 30+, and OVERRIDE_WIFI_CONFIG on API 29-.
|
||||
*/
|
||||
val configurationCompat get() = if (Build.VERSION.SDK_INT < 30) @Suppress("DEPRECATION") {
|
||||
(getWifiApConfiguration(Services.wifi) as android.net.wifi.WifiConfiguration?)?.toCompat()
|
||||
?: SoftApConfigurationCompat()
|
||||
} else configuration.toCompat()
|
||||
fun setConfigurationCompat(value: SoftApConfigurationCompat) = (if (Build.VERSION.SDK_INT >= 30) {
|
||||
setSoftApConfiguration(Services.wifi, value.toPlatform())
|
||||
} else @Suppress("DEPRECATION") {
|
||||
setWifiApConfiguration(Services.wifi, value.toWifiConfiguration())
|
||||
}) as Boolean
|
||||
@Deprecated("Use configuration instead", ReplaceWith("configuration"))
|
||||
@Suppress("DEPRECATION")
|
||||
val configurationLegacy get() = getWifiApConfiguration(Services.wifi) as android.net.wifi.WifiConfiguration?
|
||||
/**
|
||||
* Requires NETWORK_SETTINGS permission (or root).
|
||||
*/
|
||||
@get:RequiresApi(30)
|
||||
val configuration get() = getSoftApConfiguration(Services.wifi) as SoftApConfiguration
|
||||
@Deprecated("Use SoftApConfiguration instead")
|
||||
@Suppress("DEPRECATION")
|
||||
fun setConfiguration(value: android.net.wifi.WifiConfiguration?) =
|
||||
setWifiApConfiguration(Services.wifi, value) as Boolean
|
||||
fun setConfiguration(value: SoftApConfiguration) = setSoftApConfiguration(Services.wifi, value) as Boolean
|
||||
|
||||
@RequiresApi(28)
|
||||
interface SoftApCallbackCompat {
|
||||
/**
|
||||
* Called when soft AP state changes.
|
||||
*
|
||||
* @param state the new AP state. One of {@link #WIFI_AP_STATE_DISABLED},
|
||||
* {@link #WIFI_AP_STATE_DISABLING}, {@link #WIFI_AP_STATE_ENABLED},
|
||||
* {@link #WIFI_AP_STATE_ENABLING}, {@link #WIFI_AP_STATE_FAILED}
|
||||
* @param state the new AP state. One of [WIFI_AP_STATE_DISABLED], [WIFI_AP_STATE_DISABLING],
|
||||
* [WIFI_AP_STATE_ENABLED], [WIFI_AP_STATE_ENABLING], [WIFI_AP_STATE_FAILED]
|
||||
* @param failureReason reason when in failed state. One of
|
||||
* {@link #SAP_START_FAILURE_GENERAL},
|
||||
* {@link #SAP_START_FAILURE_NO_CHANNEL},
|
||||
@@ -150,7 +236,6 @@ object WifiApManager {
|
||||
@RequiresApi(30)
|
||||
fun onBlockedClientConnecting(client: Parcelable, blockedReason: Int) { }
|
||||
}
|
||||
@RequiresApi(28)
|
||||
val failureReasonLookup = ConstantLookup<WifiManager>("SAP_START_FAILURE_", "GENERAL", "NO_CHANNEL")
|
||||
@get:RequiresApi(30)
|
||||
val clientBlockLookup by lazy { ConstantLookup<WifiManager>("SAP_CLIENT_") }
|
||||
@@ -166,7 +251,6 @@ object WifiApManager {
|
||||
WifiManager::class.java.getDeclaredMethod("unregisterSoftApCallback", interfaceSoftApCallback)
|
||||
}
|
||||
|
||||
@RequiresApi(28)
|
||||
fun registerSoftApCallback(callback: SoftApCallbackCompat, executor: Executor): Any {
|
||||
val proxy = Proxy.newProxyInstance(interfaceSoftApCallback.classLoader,
|
||||
arrayOf(interfaceSoftApCallback), object : InvocationHandler {
|
||||
@@ -177,55 +261,36 @@ object WifiApManager {
|
||||
} else invokeActual(proxy, method, args)
|
||||
|
||||
private fun invokeActual(proxy: Any, method: Method, args: Array<out Any?>?): Any? {
|
||||
val noArgs = args?.size ?: 0
|
||||
return when (val name = method.name) {
|
||||
"onStateChanged" -> {
|
||||
if (noArgs != 2) Timber.w("Unexpected args for $name: ${args?.contentToString()}")
|
||||
return when {
|
||||
method.matches("onStateChanged", Integer.TYPE, Integer.TYPE) -> {
|
||||
callback.onStateChanged(args!![0] as Int, args[1] as Int)
|
||||
}
|
||||
"onNumClientsChanged" -> @Suppress("DEPRECATION") {
|
||||
method.matches("onNumClientsChanged", Integer.TYPE) -> {
|
||||
if (Build.VERSION.SDK_INT >= 30) Timber.w(Exception("Unexpected onNumClientsChanged"))
|
||||
if (noArgs != 1) Timber.w("Unexpected args for $name: ${args?.contentToString()}")
|
||||
callback.onNumClientsChanged(args!![0] as Int)
|
||||
}
|
||||
"onConnectedClientsChanged" -> @TargetApi(30) {
|
||||
method.matches1<java.util.List<*>>("onConnectedClientsChanged") -> @TargetApi(30) {
|
||||
if (Build.VERSION.SDK_INT < 30) Timber.w(Exception("Unexpected onConnectedClientsChanged"))
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
when (noArgs) {
|
||||
1 -> callback.onConnectedClientsChanged(args!![0] as List<Parcelable>)
|
||||
2 -> null // we use the old method which returns all clients in one call
|
||||
else -> {
|
||||
Timber.w("Unexpected args for $name: ${args?.contentToString()}")
|
||||
null
|
||||
callback.onConnectedClientsChanged(args!![0] as List<Parcelable>)
|
||||
}
|
||||
}
|
||||
}
|
||||
"onInfoChanged" -> @TargetApi(30) {
|
||||
if (noArgs != 1) Timber.w("Unexpected args for $name: ${args?.contentToString()}")
|
||||
val arg = args!![0]
|
||||
if (arg is List<*>) {
|
||||
if (!BuildCompat.isAtLeastS()) Timber.w(Exception("Unexpected onInfoChanged API 31+"))
|
||||
method.matches1<java.util.List<*>>("onInfoChanged") -> @TargetApi(31) {
|
||||
if (Build.VERSION.SDK_INT < 31) Timber.w(Exception("Unexpected onInfoChanged API 31+"))
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
callback.onInfoChanged(arg as List<Parcelable>)
|
||||
} else {
|
||||
when (Build.VERSION.SDK_INT) {
|
||||
30 -> { }
|
||||
in 31..Int.MAX_VALUE -> return null // ignore old version calls
|
||||
else -> Timber.w(Exception("Unexpected onInfoChanged API 30"))
|
||||
callback.onInfoChanged(args!![0] as List<Parcelable>)
|
||||
}
|
||||
Build.VERSION.SDK_INT >= 30 && method.matches("onInfoChanged", SoftApInfo.clazz) -> {
|
||||
if (Build.VERSION.SDK_INT >= 31) return null // ignore old version calls
|
||||
val arg = args!![0]
|
||||
val info = SoftApInfo(arg as Parcelable)
|
||||
callback.onInfoChanged( // check for legacy empty info with CHANNEL_WIDTH_INVALID
|
||||
if (info.frequency == 0 && info.bandwidth == 0) emptyList() else listOf(arg))
|
||||
callback.onInfoChanged(if (info.frequency == 0 && info.bandwidth ==
|
||||
SoftApConfigurationCompat.CHANNEL_WIDTH_INVALID) emptyList() else listOf(arg))
|
||||
}
|
||||
}
|
||||
"onCapabilityChanged" -> @TargetApi(30) {
|
||||
if (Build.VERSION.SDK_INT < 30) Timber.w(Exception("Unexpected onCapabilityChanged"))
|
||||
if (noArgs != 1) Timber.w("Unexpected args for $name: ${args?.contentToString()}")
|
||||
Build.VERSION.SDK_INT >= 30 && method.matches("onCapabilityChanged", SoftApCapability.clazz) -> {
|
||||
callback.onCapabilityChanged(args!![0] as Parcelable)
|
||||
}
|
||||
"onBlockedClientConnecting" -> @TargetApi(30) {
|
||||
if (Build.VERSION.SDK_INT < 30) Timber.w(Exception("Unexpected onBlockedClientConnecting"))
|
||||
if (noArgs != 2) Timber.w("Unexpected args for $name: ${args?.contentToString()}")
|
||||
Build.VERSION.SDK_INT >= 30 && method.matches("onBlockedClientConnecting", WifiClient.clazz,
|
||||
Int::class.java) -> {
|
||||
callback.onBlockedClientConnecting(args!![0] as Parcelable, args[1] as Int)
|
||||
}
|
||||
else -> callSuper(interfaceSoftApCallback, proxy, method, args)
|
||||
@@ -237,7 +302,6 @@ object WifiApManager {
|
||||
} else registerSoftApCallback(Services.wifi, proxy, null)
|
||||
return proxy
|
||||
}
|
||||
@RequiresApi(28)
|
||||
fun unregisterSoftApCallback(key: Any) = unregisterSoftApCallback(Services.wifi, key)
|
||||
|
||||
@get:RequiresApi(30)
|
||||
@@ -253,43 +317,9 @@ object WifiApManager {
|
||||
private val cancelLocalOnlyHotspotRequest by lazy {
|
||||
WifiManager::class.java.getDeclaredMethod("cancelLocalOnlyHotspotRequest")
|
||||
}
|
||||
@RequiresApi(26)
|
||||
/**
|
||||
* This is the only way to unregister requests besides app exiting.
|
||||
* Therefore, we are happy with crashing the app if reflection fails.
|
||||
*/
|
||||
fun cancelLocalOnlyHotspotRequest() = cancelLocalOnlyHotspotRequest(Services.wifi)
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
private val setWifiApEnabled by lazy {
|
||||
WifiManager::class.java.getDeclaredMethod("setWifiApEnabled",
|
||||
android.net.wifi.WifiConfiguration::class.java, Boolean::class.java)
|
||||
}
|
||||
/**
|
||||
* Start AccessPoint mode with the specified
|
||||
* configuration. If the radio is already running in
|
||||
* AP mode, update the new configuration
|
||||
* Note that starting in access point mode disables station
|
||||
* mode operation
|
||||
* @param wifiConfig SSID, security and channel details as
|
||||
* part of WifiConfiguration
|
||||
* @return {@code true} if the operation succeeds, {@code false} otherwise
|
||||
*/
|
||||
@Suppress("DEPRECATION")
|
||||
private fun WifiManager.setWifiApEnabled(wifiConfig: android.net.wifi.WifiConfiguration?, enabled: Boolean) =
|
||||
setWifiApEnabled(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: android.net.wifi.WifiConfiguration? = null) {
|
||||
Services.wifi.isWifiEnabled = false
|
||||
Services.wifi.setWifiApEnabled(wifiConfig, true)
|
||||
}
|
||||
@Suppress("DEPRECATION")
|
||||
@Deprecated("Not usable since API 26")
|
||||
fun stop() {
|
||||
Services.wifi.setWifiApEnabled(null, false)
|
||||
Services.wifi.isWifiEnabled = true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ import timber.log.Timber
|
||||
@RequiresApi(30)
|
||||
value class WifiClient(val inner: Parcelable) {
|
||||
companion object {
|
||||
private val clazz by lazy { Class.forName("android.net.wifi.WifiClient") }
|
||||
val clazz by lazy { Class.forName("android.net.wifi.WifiClient") }
|
||||
private val getMacAddress by lazy { clazz.getDeclaredMethod("getMacAddress") }
|
||||
@get:RequiresApi(31)
|
||||
private val getApInstanceIdentifier by lazy @TargetApi(31) { UnblockCentral.getApInstanceIdentifier(clazz) }
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
package be.mygod.vpnhotspot.net.wifi
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.net.MacAddress
|
||||
import android.net.wifi.ScanResult
|
||||
import android.net.wifi.WpsInfo
|
||||
import android.net.wifi.p2p.WifiP2pGroup
|
||||
import android.net.wifi.p2p.WifiP2pInfo
|
||||
import android.net.wifi.p2p.WifiP2pManager
|
||||
import androidx.annotation.RequiresApi
|
||||
import be.mygod.vpnhotspot.App.Companion.app
|
||||
import be.mygod.vpnhotspot.net.MacAddressCompat
|
||||
import be.mygod.vpnhotspot.util.callSuper
|
||||
import be.mygod.vpnhotspot.util.matches
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import timber.log.Timber
|
||||
import java.lang.reflect.InvocationHandler
|
||||
import java.lang.reflect.Method
|
||||
import java.lang.reflect.Proxy
|
||||
@@ -53,6 +56,15 @@ object WifiP2pManagerHelper {
|
||||
return result.future.await()
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission") // this method will fail correctly if permission is missing
|
||||
@RequiresApi(33)
|
||||
suspend fun WifiP2pManager.setVendorElements(c: WifiP2pManager.Channel,
|
||||
ve: List<ScanResult.InformationElement>): Int? {
|
||||
val result = ResultListener()
|
||||
setVendorElements(c, ve, result)
|
||||
return result.future.await()
|
||||
}
|
||||
|
||||
/**
|
||||
* Available since Android 4.3.
|
||||
*
|
||||
@@ -98,9 +110,8 @@ object WifiP2pManagerHelper {
|
||||
private val interfacePersistentGroupInfoListener by lazy {
|
||||
Class.forName("android.net.wifi.p2p.WifiP2pManager\$PersistentGroupInfoListener")
|
||||
}
|
||||
private val getGroupList by lazy {
|
||||
Class.forName("android.net.wifi.p2p.WifiP2pGroupList").getDeclaredMethod("getGroupList")
|
||||
}
|
||||
private val classWifiP2pGroupList by lazy { Class.forName("android.net.wifi.p2p.WifiP2pGroupList") }
|
||||
private val getGroupList by lazy { classWifiP2pGroupList.getDeclaredMethod("getGroupList") }
|
||||
private val requestPersistentGroupInfo by lazy {
|
||||
WifiP2pManager::class.java.getDeclaredMethod("requestPersistentGroupInfo",
|
||||
WifiP2pManager.Channel::class.java, interfacePersistentGroupInfoListener)
|
||||
@@ -116,9 +127,8 @@ object WifiP2pManagerHelper {
|
||||
val result = CompletableDeferred<Collection<WifiP2pGroup>>()
|
||||
requestPersistentGroupInfo(this, c, Proxy.newProxyInstance(interfacePersistentGroupInfoListener.classLoader,
|
||||
arrayOf(interfacePersistentGroupInfoListener), object : InvocationHandler {
|
||||
override fun invoke(proxy: Any, method: Method, args: Array<out Any?>?): Any? = when (method.name) {
|
||||
"onPersistentGroupInfoAvailable" -> {
|
||||
if (args?.size != 1) Timber.w(IllegalArgumentException("Unexpected args: $args"))
|
||||
override fun invoke(proxy: Any, method: Method, args: Array<out Any?>?): Any? = when {
|
||||
method.matches("onPersistentGroupInfoAvailable", classWifiP2pGroupList) -> {
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
result.complete(getGroupList(args!![0]) as Collection<WifiP2pGroup>)
|
||||
}
|
||||
@@ -128,14 +138,22 @@ object WifiP2pManagerHelper {
|
||||
return result.await()
|
||||
}
|
||||
|
||||
@SuppressLint("MissingPermission")
|
||||
suspend fun WifiP2pManager.requestConnectionInfo(c: WifiP2pManager.Channel) =
|
||||
CompletableDeferred<WifiP2pInfo?>().apply { requestConnectionInfo(c) { complete(it) } }.await()
|
||||
@SuppressLint("MissingPermission") // missing permission simply leads to null result
|
||||
@RequiresApi(29)
|
||||
suspend fun WifiP2pManager.requestDeviceAddress(c: WifiP2pManager.Channel): MacAddressCompat? {
|
||||
suspend fun WifiP2pManager.requestDeviceAddress(c: WifiP2pManager.Channel): MacAddress? {
|
||||
val future = CompletableDeferred<String?>()
|
||||
requestDeviceInfo(c) { future.complete(it?.deviceAddress) }
|
||||
return future.await()?.let {
|
||||
val address = if (it.isEmpty()) null else MacAddressCompat.fromString(it)
|
||||
val address = if (it.isEmpty()) null else MacAddress.fromString(it)
|
||||
if (address == MacAddressCompat.ANY_ADDRESS) null else address
|
||||
}
|
||||
}
|
||||
@SuppressLint("MissingPermission") // missing permission simply leads to null result
|
||||
suspend fun WifiP2pManager.requestGroupInfo(c: WifiP2pManager.Channel) =
|
||||
CompletableDeferred<WifiP2pGroup?>().apply { requestGroupInfo(c) { complete(it) } }.await()
|
||||
@RequiresApi(29)
|
||||
suspend fun WifiP2pManager.requestP2pState(c: WifiP2pManager.Channel) =
|
||||
CompletableDeferred<Int>().apply { requestP2pState(c) { complete(it) } }.await()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
package be.mygod.vpnhotspot.net.wifi
|
||||
|
||||
import android.net.wifi.WifiSsid
|
||||
import android.os.Parcelable
|
||||
import androidx.annotation.RequiresApi
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import org.jetbrains.annotations.Contract
|
||||
import java.nio.ByteBuffer
|
||||
import java.nio.CharBuffer
|
||||
import java.nio.charset.Charset
|
||||
import java.nio.charset.CodingErrorAction
|
||||
|
||||
@Parcelize
|
||||
data class WifiSsidCompat(val bytes: ByteArray) : Parcelable {
|
||||
companion object {
|
||||
private val hexTester = Regex("^(?:[0-9a-f]{2})*$", RegexOption.IGNORE_CASE)
|
||||
private val qrSanitizer = Regex("([\\\\\":;,])")
|
||||
|
||||
fun fromHex(hex: CharSequence?) = hex?.run {
|
||||
require(length % 2 == 0) { "Input should be hex: $hex" }
|
||||
WifiSsidCompat((0 until length / 2).map {
|
||||
Integer.parseInt(substring(it * 2, it * 2 + 2), 16).toByte()
|
||||
}.toByteArray())
|
||||
}
|
||||
|
||||
@Contract("null -> null; !null -> !null")
|
||||
fun fromUtf8Text(text: CharSequence?) = text?.toString()?.toByteArray()?.let { WifiSsidCompat(it) }
|
||||
|
||||
fun toMeCard(text: String) = qrSanitizer.replace(text) { "\\${it.groupValues[1]}" }
|
||||
|
||||
@RequiresApi(33)
|
||||
fun WifiSsid.toCompat() = WifiSsidCompat(bytes)
|
||||
}
|
||||
|
||||
init {
|
||||
require(bytes.size <= 32) { "${bytes.size} > 32" }
|
||||
}
|
||||
|
||||
@RequiresApi(31)
|
||||
fun toPlatform() = WifiSsid.fromBytes(bytes)
|
||||
|
||||
fun decode(charset: Charset = Charsets.UTF_8) = CharBuffer.allocate(32).run {
|
||||
val result = charset.newDecoder().apply {
|
||||
onMalformedInput(CodingErrorAction.REPORT)
|
||||
onUnmappableCharacter(CodingErrorAction.REPORT)
|
||||
}.decode(ByteBuffer.wrap(bytes), this, true)
|
||||
if (result.isError) null else flip().toString()
|
||||
}
|
||||
val hex get() = bytes.joinToString("") { "%02x".format(it.toUByte().toInt()) }
|
||||
|
||||
fun toMeCard(): String {
|
||||
val utf8 = decode() ?: return hex
|
||||
return if (hexTester.matches(utf8)) "\"$utf8\"" else toMeCard(utf8)
|
||||
}
|
||||
|
||||
override fun toString() = String(bytes)
|
||||
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (javaClass != other?.javaClass) return false
|
||||
other as WifiSsidCompat
|
||||
if (!bytes.contentEquals(other.bytes)) return false
|
||||
return true
|
||||
}
|
||||
|
||||
override fun hashCode() = bytes.contentHashCode()
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
package be.mygod.vpnhotspot.preference
|
||||
|
||||
import android.content.Context
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.ArrayAdapter
|
||||
import androidx.core.os.bundleOf
|
||||
import androidx.preference.EditTextPreferenceDialogFragmentCompat
|
||||
import be.mygod.vpnhotspot.R
|
||||
import be.mygod.vpnhotspot.widget.AlwaysAutoCompleteEditText
|
||||
|
||||
class AlwaysAutoCompleteEditTextPreferenceDialogFragment : EditTextPreferenceDialogFragmentCompat() {
|
||||
companion object {
|
||||
private const val ARG_SUGGESTIONS = "suggestions"
|
||||
}
|
||||
|
||||
fun setArguments(key: String, suggestions: Array<String>) {
|
||||
arguments = bundleOf(ARG_KEY to key, ARG_SUGGESTIONS to suggestions)
|
||||
}
|
||||
|
||||
private lateinit var editText: AlwaysAutoCompleteEditText
|
||||
|
||||
override fun onCreateDialogView(context: Context) = super.onCreateDialogView(context).apply {
|
||||
editText = AlwaysAutoCompleteEditText(context).apply {
|
||||
id = android.R.id.edit
|
||||
minHeight = resources.getDimensionPixelSize(R.dimen.touch_target_min)
|
||||
}
|
||||
val oldEditText = findViewById<View>(android.R.id.edit)!!
|
||||
val container = oldEditText.parent as ViewGroup
|
||||
container.removeView(oldEditText)
|
||||
container.addView(editText, oldEditText.layoutParams)
|
||||
}
|
||||
|
||||
override fun onBindDialogView(view: View) {
|
||||
super.onBindDialogView(view)
|
||||
editText.hint = (preference.summaryProvider as SummaryFallbackProvider).fallback
|
||||
arguments?.getStringArray(ARG_SUGGESTIONS)?.let { suggestions ->
|
||||
editText.setAdapter(ArrayAdapter(view.context, android.R.layout.select_dialog_item, suggestions))
|
||||
}
|
||||
editText.clearFocus() // having focus is buggy currently
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package be.mygod.vpnhotspot.preference
|
||||
|
||||
import android.content.Context
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.LinkProperties
|
||||
import android.net.Network
|
||||
import android.net.NetworkCapabilities
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.ArrayAdapter
|
||||
import androidx.core.os.bundleOf
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.lifecycle.withStarted
|
||||
import androidx.preference.EditTextPreferenceDialogFragmentCompat
|
||||
import be.mygod.vpnhotspot.R
|
||||
import be.mygod.vpnhotspot.util.Services
|
||||
import be.mygod.vpnhotspot.util.allInterfaceNames
|
||||
import be.mygod.vpnhotspot.util.globalNetworkRequestBuilder
|
||||
import be.mygod.vpnhotspot.widget.AlwaysAutoCompleteEditText
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class AutoCompleteNetworkPreferenceDialogFragment : EditTextPreferenceDialogFragmentCompat() {
|
||||
fun setArguments(key: String) {
|
||||
arguments = bundleOf(ARG_KEY to key)
|
||||
}
|
||||
|
||||
private lateinit var editText: AlwaysAutoCompleteEditText
|
||||
private lateinit var adapter: ArrayAdapter<String>
|
||||
private fun updateAdapter() {
|
||||
adapter.clear()
|
||||
adapter.addAll(interfaceNames.flatMap { it.value })
|
||||
}
|
||||
|
||||
private val interfaceNames = mutableMapOf<Network, List<String>>()
|
||||
private val callback = object : ConnectivityManager.NetworkCallback() {
|
||||
override fun onLinkPropertiesChanged(network: Network, properties: LinkProperties) {
|
||||
interfaceNames[network] = properties.allInterfaceNames
|
||||
lifecycleScope.launch {
|
||||
withStarted { updateAdapter() }
|
||||
}
|
||||
}
|
||||
|
||||
override fun onLost(network: Network) {
|
||||
interfaceNames.remove(network)
|
||||
lifecycleScope.launch {
|
||||
withStarted { updateAdapter() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreateDialogView(context: Context) = super.onCreateDialogView(context)!!.apply {
|
||||
val oldEditText = findViewById<View>(android.R.id.edit)!!
|
||||
val container = oldEditText.parent as ViewGroup
|
||||
container.removeView(oldEditText)
|
||||
container.addView(layoutInflater.inflate(R.layout.preference_widget_edittext_autocomplete, container, false),
|
||||
oldEditText.layoutParams)
|
||||
}
|
||||
|
||||
override fun onBindDialogView(view: View) {
|
||||
super.onBindDialogView(view)
|
||||
editText = view.findViewById(android.R.id.edit)
|
||||
editText.hint = (preference.summaryProvider as SummaryFallbackProvider).fallback
|
||||
adapter = ArrayAdapter(view.context, android.R.layout.select_dialog_item)
|
||||
editText.setAdapter(adapter)
|
||||
}
|
||||
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
Services.registerNetworkCallback(globalNetworkRequestBuilder().apply {
|
||||
removeCapability(NetworkCapabilities.NET_CAPABILITY_NOT_RESTRICTED)
|
||||
removeCapability(NetworkCapabilities.NET_CAPABILITY_TRUSTED)
|
||||
removeCapability(NetworkCapabilities.NET_CAPABILITY_NOT_VPN)
|
||||
}.build(), callback)
|
||||
}
|
||||
|
||||
override fun onStop() {
|
||||
Services.connectivity.unregisterNetworkCallback(callback)
|
||||
interfaceNames.clear()
|
||||
updateAdapter()
|
||||
super.onStop()
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,6 @@ import android.text.style.StyleSpan
|
||||
import android.util.AttributeSet
|
||||
import androidx.lifecycle.DefaultLifecycleObserver
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.preference.Preference
|
||||
import be.mygod.vpnhotspot.R
|
||||
import be.mygod.vpnhotspot.net.monitor.FallbackUpstreamMonitor
|
||||
@@ -31,7 +30,7 @@ class UpstreamsPreference(context: Context, attrs: AttributeSet) : Preference(co
|
||||
if (internet) SpannableStringBuilder(ifname).apply {
|
||||
setSpan(StyleSpan(Typeface.BOLD), 0, length, 0)
|
||||
} else ifname
|
||||
}.joinTo(SpannableStringBuilder()).let { if (it.isEmpty()) "∅" else it }
|
||||
}.joinTo(SpannableStringBuilder()).ifEmpty { "∅" }
|
||||
|
||||
override fun onAvailable(properties: LinkProperties?) {
|
||||
val result = mutableMapOf<String, Boolean>()
|
||||
@@ -51,15 +50,11 @@ class UpstreamsPreference(context: Context, attrs: AttributeSet) : Preference(co
|
||||
}
|
||||
|
||||
private val primary = Monitor()
|
||||
private val fallback: Monitor = object : Monitor() {
|
||||
override fun onFallback() {
|
||||
currentInterfaces = mapOf("<default>" to true)
|
||||
onUpdate()
|
||||
}
|
||||
}
|
||||
private val fallback = Monitor()
|
||||
|
||||
init {
|
||||
(context as LifecycleOwner).lifecycle.addObserver(this)
|
||||
onUpdate()
|
||||
}
|
||||
|
||||
override fun onStart(owner: LifecycleOwner) {
|
||||
@@ -71,8 +66,8 @@ class UpstreamsPreference(context: Context, attrs: AttributeSet) : Preference(co
|
||||
FallbackUpstreamMonitor.unregisterCallback(fallback)
|
||||
}
|
||||
|
||||
private fun onUpdate() = (context as LifecycleOwner).lifecycleScope.launchWhenStarted {
|
||||
private fun onUpdate() {
|
||||
summary = context.getText(R.string.settings_service_upstream_monitor_summary).format(
|
||||
context.resources.configuration.locale, primary.charSequence, fallback.charSequence)
|
||||
context.resources.configuration.locales[0], primary.charSequence, fallback.charSequence)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,37 +1,38 @@
|
||||
package be.mygod.vpnhotspot.room
|
||||
|
||||
import android.net.MacAddress
|
||||
import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.map
|
||||
import androidx.room.*
|
||||
import be.mygod.vpnhotspot.net.MacAddressCompat
|
||||
import be.mygod.vpnhotspot.net.MacAddressCompat.Companion.toLong
|
||||
|
||||
@Entity
|
||||
data class ClientRecord(@PrimaryKey
|
||||
val mac: Long,
|
||||
val mac: MacAddress,
|
||||
var nickname: CharSequence = "",
|
||||
var blocked: Boolean = false,
|
||||
var macLookupPending: Boolean = true) {
|
||||
@androidx.room.Dao
|
||||
abstract class Dao {
|
||||
@Query("SELECT * FROM `ClientRecord` WHERE `mac` = :mac")
|
||||
protected abstract fun lookupBlocking(mac: Long): ClientRecord?
|
||||
fun lookupOrDefaultBlocking(mac: MacAddressCompat) = lookupBlocking(mac.addr) ?: ClientRecord(mac.addr)
|
||||
protected abstract fun lookupBlocking(mac: MacAddress): ClientRecord?
|
||||
fun lookupOrDefaultBlocking(mac: MacAddress) = lookupBlocking(mac) ?: ClientRecord(mac)
|
||||
|
||||
@Query("SELECT * FROM `ClientRecord` WHERE `mac` = :mac")
|
||||
protected abstract suspend fun lookup(mac: Long): ClientRecord?
|
||||
suspend fun lookupOrDefault(mac: Long) = lookup(mac) ?: ClientRecord(mac)
|
||||
protected abstract suspend fun lookup(mac: MacAddress): ClientRecord?
|
||||
suspend fun lookupOrDefault(mac: MacAddress) = lookup(mac) ?: ClientRecord(mac)
|
||||
|
||||
@Query("SELECT * FROM `ClientRecord` WHERE `mac` = :mac")
|
||||
protected abstract fun lookupSync(mac: Long): LiveData<ClientRecord?>
|
||||
fun lookupOrDefaultSync(mac: MacAddressCompat) = lookupSync(mac.addr).map { it ?: ClientRecord(mac.addr) }
|
||||
protected abstract fun lookupSync(mac: MacAddress): LiveData<ClientRecord?>
|
||||
fun lookupOrDefaultSync(mac: MacAddress) = lookupSync(mac).map { it ?: ClientRecord(mac) }
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
protected abstract suspend fun updateInternal(value: ClientRecord): Long
|
||||
suspend fun update(value: ClientRecord) = check(updateInternal(value) == value.mac)
|
||||
suspend fun update(value: ClientRecord) = check(updateInternal(value) == value.mac.toLong())
|
||||
|
||||
@Transaction
|
||||
open suspend fun upsert(mac: MacAddressCompat, operation: suspend ClientRecord.() -> Unit) = lookupOrDefault(
|
||||
mac.addr).apply {
|
||||
open suspend fun upsert(mac: MacAddress, operation: suspend ClientRecord.() -> Unit) = lookupOrDefault(
|
||||
mac).apply {
|
||||
operation()
|
||||
update(this)
|
||||
}
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
package be.mygod.vpnhotspot.room
|
||||
|
||||
import android.net.MacAddress
|
||||
import android.text.TextUtils
|
||||
import androidx.room.TypeConverter
|
||||
import be.mygod.librootkotlinx.useParcel
|
||||
import be.mygod.vpnhotspot.net.MacAddressCompat
|
||||
import be.mygod.vpnhotspot.net.MacAddressCompat.Companion.toLong
|
||||
import timber.log.Timber
|
||||
import java.net.InetAddress
|
||||
|
||||
@@ -27,6 +30,14 @@ object Converters {
|
||||
}
|
||||
}
|
||||
|
||||
@JvmStatic
|
||||
@TypeConverter
|
||||
fun persistMacAddress(address: MacAddress) = address.toLong()
|
||||
|
||||
@JvmStatic
|
||||
@TypeConverter
|
||||
fun unpersistMacAddress(address: Long) = MacAddressCompat(address).toPlatform()
|
||||
|
||||
@JvmStatic
|
||||
@TypeConverter
|
||||
fun persistInetAddress(address: InetAddress): ByteArray = address.address
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package be.mygod.vpnhotspot.room
|
||||
|
||||
import android.net.MacAddress
|
||||
import android.os.Parcelable
|
||||
import androidx.room.*
|
||||
import kotlinx.parcelize.Parcelize
|
||||
@@ -22,7 +23,7 @@ data class TrafficRecord(
|
||||
/**
|
||||
* Foreign key/ID for (possibly non-existent, i.e. default) entry in ClientRecord.
|
||||
*/
|
||||
val mac: Long,
|
||||
val mac: MacAddress,
|
||||
/**
|
||||
* For now only stats for IPv4 will be recorded. But I'm going to put the more general class here just in case.
|
||||
*/
|
||||
@@ -58,7 +59,7 @@ data class TrafficRecord(
|
||||
/* We only want to find the last record for each chain so that we don't double count */
|
||||
WHERE TrafficRecord.mac = :mac AND Next.id IS NULL
|
||||
""")
|
||||
abstract suspend fun queryStats(mac: Long): ClientStats
|
||||
abstract suspend fun queryStats(mac: MacAddress): ClientStats
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package be.mygod.vpnhotspot.root
|
||||
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import android.os.Parcelable
|
||||
import android.os.RemoteException
|
||||
import android.provider.Settings
|
||||
@@ -30,18 +29,10 @@ fun ProcessBuilder.fixPath(redirect: Boolean = false) = apply {
|
||||
|
||||
@Parcelize
|
||||
data class Dump(val path: String, val cacheDir: File = app.deviceStorage.codeCacheDir) : RootCommandNoResult {
|
||||
@Suppress("BlockingMethodInNonBlockingContext")
|
||||
override suspend fun execute() = withContext(Dispatchers.IO) {
|
||||
FileOutputStream(path, true).use { out ->
|
||||
val process = ProcessBuilder("sh").fixPath(true).start()
|
||||
process.outputStream.bufferedWriter().use { commands ->
|
||||
// https://android.googlesource.com/platform/external/iptables/+/android-7.0.0_r1/iptables/Android.mk#34
|
||||
val iptablesSave = if (Build.VERSION.SDK_INT < 24) File(cacheDir, "iptables-save").absolutePath.also {
|
||||
commands.appendLine("ln -sf /system/bin/iptables $it")
|
||||
} else "iptables-save"
|
||||
val ip6tablesSave = if (Build.VERSION.SDK_INT < 24) File(cacheDir, "ip6tables-save").absolutePath.also {
|
||||
commands.appendLine("ln -sf /system/bin/ip6tables $it")
|
||||
} else "ip6tables-save"
|
||||
commands.appendLine("""
|
||||
|echo dumpsys ${Context.WIFI_P2P_SERVICE}
|
||||
|dumpsys ${Context.WIFI_P2P_SERVICE}
|
||||
@@ -50,13 +41,13 @@ data class Dump(val path: String, val cacheDir: File = app.deviceStorage.codeCac
|
||||
|dumpsys ${Context.CONNECTIVITY_SERVICE} tethering
|
||||
|echo
|
||||
|echo iptables -t filter
|
||||
|$iptablesSave -t filter
|
||||
|iptables-save -t filter
|
||||
|echo
|
||||
|echo iptables -t nat
|
||||
|$iptablesSave -t nat
|
||||
|iptables-save -t nat
|
||||
|echo
|
||||
|echo ip6tables-save
|
||||
|$ip6tablesSave
|
||||
|ip6tables-save
|
||||
|echo
|
||||
|echo ip rule
|
||||
|$IP rule
|
||||
@@ -125,7 +116,7 @@ class ProcessListener(private val terminateRegex: Regex,
|
||||
parent.join()
|
||||
} finally {
|
||||
parent.cancel()
|
||||
if (Build.VERSION.SDK_INT < 26) process.destroy() else if (process.isAlive) process.destroyForcibly()
|
||||
if (process.isAlive) process.destroyForcibly()
|
||||
parent.join()
|
||||
}
|
||||
}
|
||||
@@ -162,7 +153,6 @@ data class StartTethering(private val type: Int,
|
||||
|
||||
@Deprecated("Old API since API 30")
|
||||
@Parcelize
|
||||
@RequiresApi(24)
|
||||
@Suppress("DEPRECATION")
|
||||
data class StartTetheringLegacy(private val cacheDir: File, private val type: Int,
|
||||
private val showProvisioningUi: Boolean) : RootCommand<ParcelableBoolean> {
|
||||
@@ -184,7 +174,6 @@ data class StartTetheringLegacy(private val cacheDir: File, private val type: In
|
||||
}
|
||||
|
||||
@Parcelize
|
||||
@RequiresApi(24)
|
||||
data class StopTethering(private val type: Int) : RootCommandNoResult {
|
||||
override suspend fun execute(): Parcelable? {
|
||||
TetheringManager.stopTethering(type)
|
||||
@@ -209,12 +198,11 @@ data class SettingsGlobalPut(val name: String, val value: String) : RootCommandN
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("BlockingMethodInNonBlockingContext")
|
||||
override suspend fun execute() = withContext(Dispatchers.IO) {
|
||||
val process = ProcessBuilder("settings", "put", "global", name, value).fixPath(true).start()
|
||||
val error = process.inputStream.bufferedReader().readText()
|
||||
check(process.waitFor() == 0)
|
||||
if (error.isNotEmpty()) throw RemoteException(error)
|
||||
val exit = process.waitFor()
|
||||
if (exit != 0 || error.isNotEmpty()) throw RemoteException("Process exited with $exit: $error")
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package be.mygod.vpnhotspot.root
|
||||
|
||||
import android.net.MacAddress
|
||||
import android.net.wifi.ScanResult
|
||||
import android.net.wifi.p2p.WifiP2pManager
|
||||
import android.os.Looper
|
||||
import android.os.Parcelable
|
||||
@@ -11,6 +13,7 @@ import be.mygod.librootkotlinx.*
|
||||
import be.mygod.vpnhotspot.net.wifi.WifiP2pManagerHelper.deletePersistentGroup
|
||||
import be.mygod.vpnhotspot.net.wifi.WifiP2pManagerHelper.requestDeviceAddress
|
||||
import be.mygod.vpnhotspot.net.wifi.WifiP2pManagerHelper.requestPersistentGroupInfo
|
||||
import be.mygod.vpnhotspot.net.wifi.WifiP2pManagerHelper.setVendorElements
|
||||
import be.mygod.vpnhotspot.net.wifi.WifiP2pManagerHelper.setWifiP2pChannels
|
||||
import be.mygod.vpnhotspot.util.Services
|
||||
import kotlinx.parcelize.Parcelize
|
||||
@@ -35,10 +38,8 @@ object RepeaterCommands {
|
||||
|
||||
@Parcelize
|
||||
@RequiresApi(29)
|
||||
class RequestDeviceAddress : RootCommand<ParcelableLong?> {
|
||||
override suspend fun execute() = Services.p2p!!.run {
|
||||
requestDeviceAddress(obtainChannel())?.let { ParcelableLong(it.addr) }
|
||||
}
|
||||
class RequestDeviceAddress : RootCommand<MacAddress?> {
|
||||
override suspend fun execute() = Services.p2p!!.run { requestDeviceAddress(obtainChannel()) }
|
||||
}
|
||||
|
||||
@Parcelize
|
||||
@@ -55,6 +56,14 @@ object RepeaterCommands {
|
||||
}
|
||||
}
|
||||
|
||||
@Parcelize
|
||||
@RequiresApi(33)
|
||||
data class SetVendorElements(private val ve: List<ScanResult.InformationElement>) : RootCommand<ParcelableInt?> {
|
||||
override suspend fun execute() = Services.p2p!!.run {
|
||||
setVendorElements(obtainChannel(), ve)?.let { ParcelableInt(it) }
|
||||
}
|
||||
}
|
||||
|
||||
@Parcelize
|
||||
data class WriteP2pConfig(val data: String, val legacy: Boolean) : RootCommandNoResult {
|
||||
override suspend fun execute(): Parcelable? {
|
||||
|
||||
@@ -6,6 +6,8 @@ import android.util.Log
|
||||
import be.mygod.librootkotlinx.*
|
||||
import be.mygod.vpnhotspot.App.Companion.app
|
||||
import be.mygod.vpnhotspot.util.Services
|
||||
import be.mygod.vpnhotspot.util.UnblockCentral
|
||||
import com.google.firebase.crashlytics.FirebaseCrashlytics
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import timber.log.Timber
|
||||
|
||||
@@ -31,6 +33,7 @@ object RootManager : RootSession(), Logger {
|
||||
})
|
||||
Logger.me = RootManager
|
||||
Services.init { systemContext }
|
||||
UnblockCentral.needInit = false
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -42,7 +45,10 @@ object RootManager : RootSession(), Logger {
|
||||
|
||||
override suspend fun initServer(server: RootServer) {
|
||||
Logger.me = this
|
||||
server.init(app.deviceStorage)
|
||||
AppProcess.shouldRelocateHeuristics.let {
|
||||
FirebaseCrashlytics.getInstance().setCustomKey("RootManager.relocateEnabled", it)
|
||||
server.init(app.deviceStorage, it)
|
||||
}
|
||||
server.execute(RootInit())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ package be.mygod.vpnhotspot.root
|
||||
|
||||
import android.os.Parcelable
|
||||
import be.mygod.librootkotlinx.RootCommand
|
||||
import be.mygod.librootkotlinx.RootCommandOneWay
|
||||
import be.mygod.librootkotlinx.RootCommandNoResult
|
||||
import be.mygod.vpnhotspot.net.Routing
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.async
|
||||
@@ -13,8 +13,7 @@ import timber.log.Timber
|
||||
|
||||
object RoutingCommands {
|
||||
@Parcelize
|
||||
class Clean : RootCommandOneWay {
|
||||
@Suppress("BlockingMethodInNonBlockingContext")
|
||||
class Clean : RootCommandNoResult {
|
||||
override suspend fun execute() = withContext(Dispatchers.IO) {
|
||||
val process = ProcessBuilder("sh").fixPath(true).start()
|
||||
process.outputStream.bufferedWriter().use(Routing.Companion::appendCleanCommands)
|
||||
@@ -23,6 +22,7 @@ object RoutingCommands {
|
||||
else -> Timber.w("Unexpected exit code $code")
|
||||
}
|
||||
check(process.waitFor() == 0)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,13 +1,19 @@
|
||||
package be.mygod.vpnhotspot.root
|
||||
|
||||
import android.annotation.TargetApi
|
||||
import android.content.ClipData
|
||||
import android.net.wifi.SoftApConfiguration
|
||||
import android.net.wifi.WifiManager
|
||||
import android.os.Build
|
||||
import android.os.Parcelable
|
||||
import androidx.annotation.RequiresApi
|
||||
import be.mygod.librootkotlinx.ParcelableBoolean
|
||||
import be.mygod.librootkotlinx.RootCommand
|
||||
import be.mygod.librootkotlinx.RootCommandChannel
|
||||
import be.mygod.vpnhotspot.net.wifi.SoftApConfigurationCompat
|
||||
import be.mygod.vpnhotspot.App.Companion.app
|
||||
import be.mygod.vpnhotspot.R
|
||||
import be.mygod.vpnhotspot.net.wifi.WifiApManager
|
||||
import be.mygod.vpnhotspot.net.wifi.WifiClient
|
||||
import be.mygod.vpnhotspot.widget.SmartSnackbar
|
||||
import kotlinx.coroutines.*
|
||||
import kotlinx.coroutines.channels.*
|
||||
@@ -15,7 +21,6 @@ import kotlinx.parcelize.Parcelize
|
||||
import timber.log.Timber
|
||||
|
||||
object WifiApCommands {
|
||||
@RequiresApi(28)
|
||||
sealed class SoftApCallbackParcel : Parcelable {
|
||||
abstract fun dispatch(callback: WifiApManager.SoftApCallbackCompat)
|
||||
|
||||
@@ -55,7 +60,6 @@ object WifiApCommands {
|
||||
}
|
||||
|
||||
@Parcelize
|
||||
@RequiresApi(28)
|
||||
class RegisterSoftApCallback : RootCommandChannel<SoftApCallbackParcel> {
|
||||
override fun create(scope: CoroutineScope) = scope.produce(capacity = capacity) {
|
||||
val finish = CompletableDeferred<Unit>()
|
||||
@@ -111,7 +115,6 @@ object WifiApCommands {
|
||||
private val callbacks = mutableSetOf<WifiApManager.SoftApCallbackCompat>()
|
||||
private val lastCallback = AutoFiringCallbacks()
|
||||
private var rootCallbackJob: Job? = null
|
||||
@RequiresApi(28)
|
||||
private suspend fun handleChannel(channel: ReceiveChannel<SoftApCallbackParcel>) = channel.consumeEach { parcel ->
|
||||
when (parcel) {
|
||||
is SoftApCallbackParcel.OnStateChanged -> synchronized(callbacks) { lastCallback.state = parcel }
|
||||
@@ -121,10 +124,22 @@ object WifiApCommands {
|
||||
}
|
||||
is SoftApCallbackParcel.OnInfoChanged -> synchronized(callbacks) { lastCallback.info = parcel }
|
||||
is SoftApCallbackParcel.OnCapabilityChanged -> synchronized(callbacks) { lastCallback.capability = parcel }
|
||||
is SoftApCallbackParcel.OnBlockedClientConnecting -> @TargetApi(30) { // passively consume events
|
||||
val client = WifiClient(parcel.client)
|
||||
val macAddress = client.macAddress
|
||||
var name = macAddress.toString()
|
||||
if (Build.VERSION.SDK_INT >= 31) client.apInstanceIdentifier?.let { name += "%$it" }
|
||||
val reason = WifiApManager.clientBlockLookup(parcel.blockedReason, true)
|
||||
Timber.i("$name blocked from connecting: $reason (${parcel.blockedReason})")
|
||||
SmartSnackbar.make(app.getString(R.string.tethering_manage_wifi_client_blocked, name, reason)).apply {
|
||||
action(R.string.tethering_manage_wifi_copy_mac) {
|
||||
app.clipboard.setPrimaryClip(ClipData.newPlainText(null, macAddress.toString()))
|
||||
}
|
||||
}.show()
|
||||
}
|
||||
}
|
||||
for (callback in synchronized(callbacks) { callbacks.toList() }) parcel.dispatch(callback)
|
||||
}
|
||||
@RequiresApi(28)
|
||||
fun registerSoftApCallback(callback: WifiApManager.SoftApCallbackCompat) = synchronized(callbacks) {
|
||||
val wasEmpty = callbacks.isEmpty()
|
||||
callbacks.add(callback)
|
||||
@@ -141,7 +156,6 @@ object WifiApCommands {
|
||||
null
|
||||
} else lastCallback
|
||||
}?.toSequence()?.forEach { it?.dispatch(callback) }
|
||||
@RequiresApi(28)
|
||||
fun unregisterSoftApCallback(callback: WifiApManager.SoftApCallbackCompat) = synchronized(callbacks) {
|
||||
if (callbacks.remove(callback) && callbacks.isEmpty()) {
|
||||
rootCallbackJob!!.cancel()
|
||||
@@ -150,13 +164,29 @@ object WifiApCommands {
|
||||
}
|
||||
|
||||
@Parcelize
|
||||
class GetConfiguration : RootCommand<SoftApConfigurationCompat> {
|
||||
override suspend fun execute() = WifiApManager.configurationCompat
|
||||
@Deprecated("Use GetConfiguration instead", ReplaceWith("GetConfiguration"))
|
||||
@Suppress("DEPRECATION")
|
||||
class GetConfigurationLegacy : RootCommand<android.net.wifi.WifiConfiguration?> {
|
||||
override suspend fun execute() = WifiApManager.configurationLegacy
|
||||
}
|
||||
@Parcelize
|
||||
@RequiresApi(30)
|
||||
class GetConfiguration : RootCommand<SoftApConfiguration> {
|
||||
override suspend fun execute() = WifiApManager.configuration
|
||||
}
|
||||
|
||||
@Parcelize
|
||||
data class SetConfiguration(val configuration: SoftApConfigurationCompat) : RootCommand<ParcelableBoolean> {
|
||||
override suspend fun execute() = ParcelableBoolean(WifiApManager.setConfigurationCompat(configuration))
|
||||
@Deprecated("Use SetConfiguration instead", ReplaceWith("SetConfiguration"))
|
||||
@Suppress("DEPRECATION")
|
||||
data class SetConfigurationLegacy(
|
||||
val configuration: android.net.wifi.WifiConfiguration?,
|
||||
) : RootCommand<ParcelableBoolean> {
|
||||
override suspend fun execute() = ParcelableBoolean(WifiApManager.setConfiguration(configuration))
|
||||
}
|
||||
@Parcelize
|
||||
@RequiresApi(30)
|
||||
data class SetConfiguration(val configuration: SoftApConfiguration) : RootCommand<ParcelableBoolean> {
|
||||
override suspend fun execute() = ParcelableBoolean(WifiApManager.setConfiguration(configuration))
|
||||
}
|
||||
|
||||
@Parcelize
|
||||
|
||||
12
mobile/src/main/java/be/mygod/vpnhotspot/util/AppUpdate.kt
Normal file
12
mobile/src/main/java/be/mygod/vpnhotspot/util/AppUpdate.kt
Normal file
@@ -0,0 +1,12 @@
|
||||
package be.mygod.vpnhotspot.util
|
||||
|
||||
import android.app.Activity
|
||||
|
||||
interface AppUpdate {
|
||||
class IgnoredException(cause: Throwable?) : RuntimeException(cause)
|
||||
|
||||
val downloaded: Boolean? get() = null
|
||||
val message: String? get() = null
|
||||
val stalenessDays: Int? get() = null
|
||||
fun updateForResult(activity: Activity, requestCode: Int): Unit = error("Update not supported")
|
||||
}
|
||||
@@ -9,10 +9,10 @@ import timber.log.Timber
|
||||
|
||||
class ConstantLookup(private val prefix: String, private val lookup29: Array<out String?>,
|
||||
private val clazz: () -> Class<*>) {
|
||||
private val lookup by lazy {
|
||||
val lookup by lazy {
|
||||
SparseArrayCompat<String>().apply {
|
||||
for (field in clazz().declaredFields) try {
|
||||
if (field.name.startsWith(prefix)) put(field.getInt(null), field.name)
|
||||
if (field?.type == Int::class.java && field.name.startsWith(prefix)) put(field.getInt(null), field.name)
|
||||
} catch (e: Exception) {
|
||||
Timber.w(e)
|
||||
}
|
||||
@@ -30,17 +30,15 @@ class ConstantLookup(private val prefix: String, private val lookup29: Array<out
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("FunctionName")
|
||||
fun ConstantLookup(prefix: String, vararg lookup29: String?, clazz: () -> Class<*>) =
|
||||
ConstantLookup(prefix, lookup29, clazz)
|
||||
@Suppress("FunctionName")
|
||||
inline fun <reified T> ConstantLookup(prefix: String, vararg lookup29: String?) =
|
||||
ConstantLookup(prefix, lookup29) { T::class.java }
|
||||
|
||||
class LongConstantLookup(private val clazz: Class<*>, private val prefix: String) {
|
||||
private val lookup = LongSparseArray<String>().apply {
|
||||
for (field in clazz.declaredFields) try {
|
||||
if (field.name.startsWith(prefix)) put(field.getLong(null), field.name)
|
||||
if (field.type == Long::class.java && field.name.startsWith(prefix)) put(field.getLong(null), field.name)
|
||||
} catch (e: Exception) {
|
||||
Timber.w(e)
|
||||
}
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
package be.mygod.vpnhotspot.util
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.annotation.TargetApi
|
||||
import android.app.Application
|
||||
import android.content.Context
|
||||
|
||||
@SuppressLint("Registered")
|
||||
@TargetApi(24)
|
||||
class DeviceStorageApp(context: Context) : Application() {
|
||||
init {
|
||||
attachBaseContext(context.createDeviceProtectedStorageContext())
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
package be.mygod.vpnhotspot.util
|
||||
|
||||
import android.content.ComponentName
|
||||
import android.content.Intent
|
||||
import android.content.ServiceConnection
|
||||
import android.os.Build
|
||||
import android.os.DeadObjectException
|
||||
import android.os.IBinder
|
||||
import android.service.quicksettings.Tile
|
||||
import android.service.quicksettings.TileService
|
||||
import androidx.annotation.RequiresApi
|
||||
import be.mygod.vpnhotspot.BootReceiver
|
||||
|
||||
@RequiresApi(24)
|
||||
abstract class KillableTileService : TileService(), ServiceConnection {
|
||||
protected var tapPending = false
|
||||
|
||||
@@ -25,4 +26,10 @@ abstract class KillableTileService : TileService(), ServiceConnection {
|
||||
onClick()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onBind(intent: Intent?) = try {
|
||||
super.onBind(intent)
|
||||
} catch (_: DeadObjectException) {
|
||||
null
|
||||
}.also { BootReceiver.startIfEnabled() }
|
||||
}
|
||||
|
||||
43
mobile/src/main/java/be/mygod/vpnhotspot/util/RangeInput.kt
Normal file
43
mobile/src/main/java/be/mygod/vpnhotspot/util/RangeInput.kt
Normal file
@@ -0,0 +1,43 @@
|
||||
package be.mygod.vpnhotspot.util
|
||||
|
||||
object RangeInput {
|
||||
fun toString(input: IntArray) = StringBuilder().apply {
|
||||
if (input.isEmpty()) return@apply
|
||||
input.sort()
|
||||
var pending: Int? = null
|
||||
var last = input[0]
|
||||
append(last)
|
||||
for (channel in input.asSequence().drop(1)) {
|
||||
if (channel == last + 1) pending = channel else {
|
||||
pending?.let {
|
||||
append('-')
|
||||
append(it)
|
||||
pending = null
|
||||
}
|
||||
append(",\u200b") // zero-width space to save space
|
||||
append(channel)
|
||||
}
|
||||
last = channel
|
||||
}
|
||||
pending?.let {
|
||||
append('-')
|
||||
append(it)
|
||||
}
|
||||
}.toString()
|
||||
fun toString(input: Set<Int>?) = input?.run { toString(toIntArray()) }
|
||||
|
||||
fun fromString(input: CharSequence?, min: Int = 1, max: Int = 999) = mutableSetOf<Int>().apply {
|
||||
if (input == null) return@apply
|
||||
for (unit in input.split(',')) {
|
||||
if (unit.isBlank()) continue
|
||||
val blocks = unit.split('-', limit = 2).map { i ->
|
||||
i.trim { it == '\u200b' || it.isWhitespace() }.toInt()
|
||||
}
|
||||
require(blocks[0] in min..max) { "Out of range: ${blocks[0]}" }
|
||||
if (blocks.size == 2) {
|
||||
require(blocks[1] in min..max) { "Out of range: ${blocks[1]}" }
|
||||
addAll(blocks[0]..blocks[1])
|
||||
} else add(blocks[0])
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -28,8 +28,8 @@ class RootSession : AutoCloseable {
|
||||
|
||||
private var server: RootServer? = runBlocking { RootManager.acquire() }
|
||||
override fun close() {
|
||||
server = null
|
||||
server?.let { runBlocking { RootManager.release(it) } }
|
||||
server = null
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -2,8 +2,11 @@ package be.mygod.vpnhotspot.util
|
||||
|
||||
import android.content.Context
|
||||
import android.net.ConnectivityManager
|
||||
import android.net.NetworkRequest
|
||||
import android.net.wifi.WifiManager
|
||||
import android.net.wifi.p2p.WifiP2pManager
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import androidx.core.content.getSystemService
|
||||
import timber.log.Timber
|
||||
|
||||
@@ -14,6 +17,7 @@ object Services {
|
||||
contextInit = context
|
||||
}
|
||||
|
||||
val mainHandler by lazy { Handler(Looper.getMainLooper()) }
|
||||
val connectivity by lazy { context.getSystemService<ConnectivityManager>()!! }
|
||||
val p2p by lazy {
|
||||
try {
|
||||
@@ -24,4 +28,7 @@ object Services {
|
||||
}
|
||||
}
|
||||
val wifi by lazy { context.getSystemService<WifiManager>()!! }
|
||||
|
||||
fun registerNetworkCallback(request: NetworkRequest, networkCallback: ConnectivityManager.NetworkCallback) =
|
||||
connectivity.registerNetworkCallback(request, networkCallback, mainHandler)
|
||||
}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
package be.mygod.vpnhotspot.util
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.net.MacAddress
|
||||
import android.net.wifi.SoftApConfiguration
|
||||
import android.net.wifi.p2p.WifiP2pConfig
|
||||
import androidx.annotation.RequiresApi
|
||||
import timber.log.Timber
|
||||
import be.mygod.vpnhotspot.App.Companion.app
|
||||
import me.weishu.reflection.Reflection
|
||||
|
||||
/**
|
||||
* The central object for accessing all the useful blocked APIs. Thanks Google!
|
||||
@@ -12,26 +14,19 @@ import timber.log.Timber
|
||||
* Lazy cannot be used directly as it will create inner classes.
|
||||
*/
|
||||
@SuppressLint("BlockedPrivateApi", "DiscouragedPrivateApi")
|
||||
@Suppress("FunctionName")
|
||||
object UnblockCentral {
|
||||
var needInit = true
|
||||
/**
|
||||
* Retrieve this property before doing dangerous shit.
|
||||
*/
|
||||
@get:RequiresApi(28)
|
||||
private val init by lazy {
|
||||
try {
|
||||
Class.forName("dalvik.system.VMDebug").getDeclaredMethod("allowHiddenApiReflectionFrom", Class::class.java)
|
||||
.invoke(null, UnblockCentral::class.java)
|
||||
true
|
||||
} catch (e: ReflectiveOperationException) {
|
||||
Timber.w(e)
|
||||
false
|
||||
}
|
||||
}
|
||||
private val init by lazy { if (needInit) check(Reflection.unseal(app.deviceStorage) == 0) }
|
||||
|
||||
@RequiresApi(31)
|
||||
fun setUserConfiguration(clazz: Class<*>) = init.let {
|
||||
clazz.getDeclaredMethod("setUserConfiguration", Boolean::class.java)
|
||||
@RequiresApi(33)
|
||||
fun getCountryCode(clazz: Class<*>) = init.let { clazz.getDeclaredMethod("getCountryCode") }
|
||||
|
||||
@RequiresApi(33)
|
||||
fun setRandomizedMacAddress(clazz: Class<*>) = init.let {
|
||||
clazz.getDeclaredMethod("setRandomizedMacAddress", MacAddress::class.java)
|
||||
}
|
||||
|
||||
@get:RequiresApi(31)
|
||||
|
||||
@@ -1,23 +1,16 @@
|
||||
package be.mygod.vpnhotspot.util
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.annotation.TargetApi
|
||||
import android.content.*
|
||||
import android.content.res.Resources
|
||||
import android.net.InetAddresses
|
||||
import android.net.LinkProperties
|
||||
import android.net.RouteInfo
|
||||
import android.net.*
|
||||
import android.os.Build
|
||||
import android.os.RemoteException
|
||||
import android.system.ErrnoException
|
||||
import android.system.Os
|
||||
import android.system.OsConstants
|
||||
import android.text.*
|
||||
import android.view.MenuItem
|
||||
import android.view.View
|
||||
import android.widget.ImageView
|
||||
import androidx.annotation.DrawableRes
|
||||
import androidx.annotation.RequiresApi
|
||||
import androidx.core.net.toUri
|
||||
import androidx.core.view.isVisible
|
||||
import androidx.databinding.BindingAdapter
|
||||
@@ -26,18 +19,23 @@ import androidx.fragment.app.FragmentManager
|
||||
import be.mygod.vpnhotspot.App.Companion.app
|
||||
import be.mygod.vpnhotspot.net.MacAddressCompat
|
||||
import be.mygod.vpnhotspot.widget.SmartSnackbar
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.GlobalScope
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import timber.log.Timber
|
||||
import java.io.File
|
||||
import java.io.FileNotFoundException
|
||||
import java.io.IOException
|
||||
import java.lang.invoke.MethodHandles
|
||||
import java.lang.reflect.InvocationHandler
|
||||
import java.lang.reflect.InvocationTargetException
|
||||
import java.lang.reflect.Method
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.InetAddress
|
||||
import java.net.NetworkInterface
|
||||
import java.net.SocketException
|
||||
import java.util.*
|
||||
import java.net.URL
|
||||
import java.util.Locale
|
||||
import kotlin.coroutines.resumeWithException
|
||||
|
||||
tailrec fun Throwable.getRootCause(): Throwable {
|
||||
if (this is InvocationTargetException || this is RemoteException) return (cause ?: return this).getRootCause()
|
||||
@@ -55,6 +53,10 @@ fun Long.toPluralInt(): Int {
|
||||
return (this % 1000000000).toInt() + 1000000000
|
||||
}
|
||||
|
||||
fun Method.matches(name: String, vararg classes: Class<*>) = this.name == name && parameterCount == classes.size &&
|
||||
classes.indices.all { i -> parameters[i].type == classes[i] }
|
||||
inline fun <reified T> Method.matches1(name: String) = matches(name, T::class.java)
|
||||
|
||||
fun Context.ensureReceiverUnregistered(receiver: BroadcastReceiver) {
|
||||
try {
|
||||
unregisterReceiver(receiver)
|
||||
@@ -143,12 +145,13 @@ fun makeIpSpan(ip: InetAddress) = ip.hostAddress.let {
|
||||
}
|
||||
}
|
||||
fun makeMacSpan(mac: String) = if (app.hasTouch) SpannableString(mac).apply {
|
||||
setSpan(CustomTabsUrlSpan("https://macvendors.co/results/$mac"), 0, length, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
|
||||
setSpan(CustomTabsUrlSpan("https://maclookup.app/search/result?mac=$mac"), 0, length,
|
||||
Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
|
||||
} else mac
|
||||
|
||||
fun NetworkInterface.formatAddresses(macOnly: Boolean = false) = SpannableStringBuilder().apply {
|
||||
try {
|
||||
val address = hardwareAddress?.let(MacAddressCompat::fromBytes)
|
||||
val address = hardwareAddress?.let(MacAddress::fromBytes)
|
||||
if (address != null && address != MacAddressCompat.ANY_ADDRESS) appendLine(makeMacSpan(address.toString()))
|
||||
} catch (e: IllegalArgumentException) {
|
||||
Timber.w(e)
|
||||
@@ -200,8 +203,7 @@ fun Resources.findIdentifier(name: String, defType: String, defPackage: String,
|
||||
if (alternativePackage != null && it == 0) getIdentifier(name, defType, alternativePackage) else it
|
||||
}
|
||||
|
||||
@get:RequiresApi(26)
|
||||
private val newLookup by lazy @TargetApi(26) {
|
||||
private val newLookup by lazy {
|
||||
MethodHandles.Lookup::class.java.getDeclaredConstructor(Class::class.java, Int::class.java).apply {
|
||||
isAccessible = true
|
||||
}
|
||||
@@ -213,8 +215,12 @@ private val newLookup by lazy @TargetApi(26) {
|
||||
* See also: https://stackoverflow.com/a/49532463/2245107
|
||||
*/
|
||||
fun InvocationHandler.callSuper(interfaceClass: Class<*>, proxy: Any, method: Method, args: Array<out Any?>?) = when {
|
||||
Build.VERSION.SDK_INT >= 26 && method.isDefault -> newLookup.newInstance(interfaceClass, 0xf) // ALL_MODES
|
||||
.`in`(interfaceClass).unreflectSpecial(method, interfaceClass).bindTo(proxy).run {
|
||||
method.isDefault -> try {
|
||||
newLookup.newInstance(interfaceClass, 0xf) // ALL_MODES
|
||||
} catch (e: ReflectiveOperationException) {
|
||||
Timber.w(e)
|
||||
MethodHandles.lookup().`in`(interfaceClass)
|
||||
}.unreflectSpecial(method, interfaceClass).bindTo(proxy).run {
|
||||
if (args == null) invokeWithArguments() else invokeWithArguments(*args)
|
||||
}
|
||||
// otherwise, we just redispatch it to InvocationHandler
|
||||
@@ -234,13 +240,26 @@ fun InvocationHandler.callSuper(interfaceClass: Class<*>, proxy: Any, method: Me
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("FunctionName")
|
||||
fun if_nametoindex(ifname: String) = if (Build.VERSION.SDK_INT >= 26) {
|
||||
Os.if_nametoindex(ifname)
|
||||
} else try {
|
||||
File("/sys/class/net/$ifname/ifindex").inputStream().bufferedReader().use { it.readLine().trim().toInt() }
|
||||
} catch (_: FileNotFoundException) {
|
||||
NetworkInterface.getByName(ifname)?.index ?: 0
|
||||
} catch (e: IOException) {
|
||||
if ((e.cause as? ErrnoException)?.errno == OsConstants.ENODEV) 0 else throw e
|
||||
fun globalNetworkRequestBuilder() = NetworkRequest.Builder().apply {
|
||||
if (Build.VERSION.SDK_INT >= 31) setIncludeOtherUidNetworks(true)
|
||||
}
|
||||
|
||||
suspend fun <T> connectCancellable(url: String, block: suspend (HttpURLConnection) -> T): T {
|
||||
@Suppress("BlockingMethodInNonBlockingContext")
|
||||
val conn = URL(url).openConnection() as HttpURLConnection
|
||||
return suspendCancellableCoroutine { cont ->
|
||||
val job = GlobalScope.launch(Dispatchers.IO) {
|
||||
try {
|
||||
cont.resume(block(conn)) { cont.resumeWithException(it) }
|
||||
} catch (e: Throwable) {
|
||||
cont.resumeWithException(e)
|
||||
} finally {
|
||||
conn.disconnect()
|
||||
}
|
||||
}
|
||||
cont.invokeOnCancellation {
|
||||
job.cancel(it as? CancellationException)
|
||||
conn.disconnect()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,14 +5,15 @@ import android.graphics.Rect
|
||||
import android.util.AttributeSet
|
||||
import android.view.View
|
||||
import androidx.appcompat.widget.AppCompatAutoCompleteTextView
|
||||
import be.mygod.vpnhotspot.R
|
||||
|
||||
/**
|
||||
* Based on: https://gist.github.com/furycomptuers/4961368
|
||||
*/
|
||||
class AlwaysAutoCompleteEditText @JvmOverloads constructor(context: Context, attrs: AttributeSet? = null,
|
||||
defStyleAttr: Int = R.attr.autoCompleteTextViewStyle) :
|
||||
AppCompatAutoCompleteTextView(context, attrs, defStyleAttr) {
|
||||
class AlwaysAutoCompleteEditText @JvmOverloads constructor(
|
||||
context: Context,
|
||||
attrs: AttributeSet? = null,
|
||||
defStyleAttr: Int = androidx.appcompat.R.attr.autoCompleteTextViewStyle,
|
||||
) : AppCompatAutoCompleteTextView(context, attrs, defStyleAttr) {
|
||||
override fun enoughToFilter() = true
|
||||
|
||||
override fun onFocusChanged(focused: Boolean, direction: Int, previouslyFocusedRect: Rect?) {
|
||||
|
||||
10
mobile/src/main/res/drawable/ic_action_update.xml
Normal file
10
mobile/src/main/res/drawable/ic_action_update.xml
Normal file
@@ -0,0 +1,10 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24"
|
||||
android:tint="?attr/colorControlNormal">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M21,10.12h-6.78l2.74,-2.82c-2.73,-2.7 -7.15,-2.8 -9.88,-0.1c-2.73,2.71 -2.73,7.08 0,9.79s7.15,2.71 9.88,0C18.32,15.65 19,14.08 19,12.1h2c0,1.98 -0.88,4.55 -2.64,6.29c-3.51,3.48 -9.21,3.48 -12.72,0c-3.5,-3.47 -3.53,-9.11 -0.02,-12.58s9.14,-3.47 12.65,0L21,3V10.12zM12.5,8v4.25l3.5,2.08l-0.72,1.21L11,13V8H12.5z"/>
|
||||
</vector>
|
||||
5
mobile/src/main/res/drawable/ic_av_closed_caption.xml
Normal file
5
mobile/src/main/res/drawable/ic_av_closed_caption.xml
Normal file
@@ -0,0 +1,5 @@
|
||||
<vector android:height="24dp" android:tint="#000000"
|
||||
android:viewportHeight="24" android:viewportWidth="24"
|
||||
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<path android:fillColor="@android:color/white" android:pathData="M19,4L5,4c-1.11,0 -2,0.9 -2,2v12c0,1.1 0.89,2 2,2h14c1.1,0 2,-0.9 2,-2L21,6c0,-1.1 -0.9,-2 -2,-2zM11,11L9.5,11v-0.5h-2v3h2L9.5,13L11,13v1c0,0.55 -0.45,1 -1,1L7,15c-0.55,0 -1,-0.45 -1,-1v-4c0,-0.55 0.45,-1 1,-1h3c0.55,0 1,0.45 1,1v1zM18,11h-1.5v-0.5h-2v3h2L16.5,13L18,13v1c0,0.55 -0.45,1 -1,1h-3c-0.55,0 -1,-0.45 -1,-1v-4c0,-0.55 0.45,-1 1,-1h3c0.55,0 1,0.45 1,1v1z"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,5 @@
|
||||
<vector android:height="24dp" android:tint="#000000"
|
||||
android:viewportHeight="24" android:viewportWidth="24"
|
||||
android:width="24dp" xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<path android:fillColor="@android:color/white" android:pathData="M19.5,5.5v13h-15v-13h15zM19,4L5,4c-1.11,0 -2,0.9 -2,2v12c0,1.1 0.89,2 2,2h14c1.1,0 2,-0.9 2,-2L21,6c0,-1.1 -0.9,-2 -2,-2zM11,11L9.5,11v-0.5h-2v3h2L9.5,13L11,13v1c0,0.55 -0.45,1 -1,1L7,15c-0.55,0 -1,-0.45 -1,-1v-4c0,-0.55 0.45,-1 1,-1h3c0.55,0 1,0.45 1,1v1zM18,11h-1.5v-0.5h-2v3h2L16.5,13L18,13v1c0,0.55 -0.45,1 -1,1h-3c-0.55,0 -1,-0.45 -1,-1v-4c0,-0.55 0.45,-1 1,-1h3c0.55,0 1,0.45 1,1v1z"/>
|
||||
</vector>
|
||||
10
mobile/src/main/res/drawable/ic_file_downloading.xml
Normal file
10
mobile/src/main/res/drawable/ic_file_downloading.xml
Normal file
@@ -0,0 +1,10 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24"
|
||||
android:tint="?attr/colorControlNormal">
|
||||
<path
|
||||
android:fillColor="@android:color/white"
|
||||
android:pathData="M18.32,4.26C16.84,3.05 15.01,2.25 13,2.05v2.02c1.46,0.18 2.79,0.76 3.9,1.62L18.32,4.26zM19.93,11h2.02c-0.2,-2.01 -1,-3.84 -2.21,-5.32L18.31,7.1C19.17,8.21 19.75,9.54 19.93,11zM18.31,16.9l1.43,1.43c1.21,-1.48 2.01,-3.32 2.21,-5.32h-2.02C19.75,14.46 19.17,15.79 18.31,16.9zM13,19.93v2.02c2.01,-0.2 3.84,-1 5.32,-2.21l-1.43,-1.43C15.79,19.17 14.46,19.75 13,19.93zM13,12V7h-2v5H7l5,5l5,-5H13zM11,19.93v2.02c-5.05,-0.5 -9,-4.76 -9,-9.95s3.95,-9.45 9,-9.95v2.02C7.05,4.56 4,7.92 4,12S7.05,19.44 11,19.93z"/>
|
||||
</vector>
|
||||
5
mobile/src/main/res/drawable/ic_launcher_monochrome.xml
Normal file
5
mobile/src/main/res/drawable/ic_launcher_monochrome.xml
Normal file
@@ -0,0 +1,5 @@
|
||||
<vector android:height="108dp" android:viewportHeight="108.0"
|
||||
android:viewportWidth="108.0" android:width="108dp" xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<path android:fillColor="#7000" android:pathData="M48,50a4,4 0,1 0,4 4A4,4 0,0 0,48 50ZM60,54A12,12 0,1 0,42 64.38l2,-3.48a8,8 0,1 1,8 0l2,3.48A12,12 0,0 0,60 54ZM48,34A20,20 0,0 0,38 71.3l2,-3.46a16,16 0,1 1,16 0l2,3.46A20,20 0,0 0,48 34Z"/>
|
||||
<path android:fillColor="#000" android:pathData="M59.3,50a12,12 0,1 0,0 8H68v8h8V58h4V50ZM48,58a4,4 0,1 1,4 -4A4,4 0,0 1,48 58Z"/>
|
||||
</vector>
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user