Merge branch 'master' into temp-hotspot-use-system

This commit is contained in:
Mygod
2023-03-02 23:19:51 -05:00
148 changed files with 3939 additions and 3409 deletions

View File

@@ -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
View File

@@ -1,15 +1,19 @@
# VPN Hotspot
[![CircleCI](https://circleci.com/gh/Mygod/VPNHotspot.svg?style=shield)](https://circleci.com/gh/Mygod/VPNHotspot)
[![API](https://img.shields.io/badge/API-21%2B-brightgreen.svg?style=flat)](https://android-arsenal.com/api?level=21)
[![API](https://img.shields.io/badge/API-28%2B-brightgreen.svg?style=flat)](https://android-arsenal.com/api?level=28)
[![Releases](https://img.shields.io/github/downloads/Mygod/VPNHotspot/total.svg)](https://github.com/Mygod/VPNHotspot/releases)
[![Language: Kotlin](https://img.shields.io/github/languages/top/Mygod/VPNHotspot.svg)](https://github.com/Mygod/VPNHotspot/search?l=kotlin)
[![Codacy Badge](https://api.codacy.com/project/badge/Grade/e70e52b1a58045819b505c09edcae816)](https://www.codacy.com/app/Mygod/VPNHotspot?utm_source=github.com&utm_medium=referral&utm_content=Mygod/VPNHotspot&utm_campaign=Badge_Grade)
[![Codacy Badge](https://app.codacy.com/project/badge/Grade/e70e52b1a58045819b505c09edcae816)](https://www.codacy.com/gh/Mygod/VPNHotspot/dashboard?utm_source=github.com&utm_medium=referral&utm_content=Mygod/VPNHotspot&utm_campaign=Badge_Grade)
[![License](https://img.shields.io/github/license/Mygod/VPNHotspot.svg)](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`.

View File

@@ -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)
}

View File

@@ -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:

View File

@@ -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

Binary file not shown.

View File

@@ -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
View File

@@ -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
View File

@@ -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

View File

@@ -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")
}

View File

@@ -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>

View File

@@ -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[]);
}

View File

@@ -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')"
]
}
}

View File

@@ -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()
}

View File

@@ -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)
}
}
}

View File

@@ -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>

View File

@@ -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"
}
}

View File

@@ -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)
}
}

View File

@@ -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")
}
}
}

View File

@@ -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()
}
}
}

View File

@@ -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

View File

@@ -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

View File

@@ -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 {

View File

@@ -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()
}

View File

@@ -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
}
}
}
}

View File

@@ -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()
}
}

View File

@@ -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 }

View File

@@ -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()
}
}
}

View File

@@ -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
}

View File

@@ -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()
}
}

View File

@@ -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
}

View File

@@ -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")
}
}
}

View File

@@ -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)
}
}
}

View File

@@ -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() }
}
}

View File

@@ -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

View File

@@ -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 }
}
}
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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))

View File

@@ -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)
}
}

View File

@@ -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)))
}
}

View File

@@ -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()

View File

@@ -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()

View File

@@ -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()

View File

@@ -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 {

View File

@@ -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))

View File

@@ -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
}
}

View File

@@ -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 -> { }
}
}

View File

@@ -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)
}
}
}

View File

@@ -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() }
}
}
}

View File

@@ -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)
}
}
}

View File

@@ -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)

View File

@@ -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())
}

View File

@@ -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

View File

@@ -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)

View File

@@ -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

View File

@@ -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>()
}

View File

@@ -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
}
}

View File

@@ -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
}
}

View File

@@ -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) {

View File

@@ -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()
}
}

View File

@@ -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 }
}

View File

@@ -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>()

View File

@@ -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
}
}

View File

@@ -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 }

View File

@@ -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
}
}

View File

@@ -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;")

View File

@@ -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)

View File

@@ -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) }
}

View File

@@ -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 -> {

View File

@@ -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
}
}

View File

@@ -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) }

View File

@@ -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()
}

View File

@@ -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()
}

View File

@@ -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
}
}

View File

@@ -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()
}
}

View File

@@ -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)
}
}

View File

@@ -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)
}

View File

@@ -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

View File

@@ -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
}
}

View File

@@ -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
}
}

View File

@@ -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? {

View File

@@ -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())
}
}

View File

@@ -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
}
}

View File

@@ -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

View 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")
}

View File

@@ -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)
}

View File

@@ -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())

View File

@@ -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() }
}

View 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])
}
}
}

View File

@@ -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
}
/**

View File

@@ -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)
}

View File

@@ -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)

View File

@@ -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()
}
}
}

View File

@@ -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?) {

View 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>

View 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>

View 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.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>

View 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>

View 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