diff --git a/README.md b/README.md index a0fd2398..b5b3ec33 100644 --- a/README.md +++ b/README.md @@ -302,6 +302,7 @@ Greylisted/blacklisted APIs or internal constants: (some constants are hardcoded * (since API 28) `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/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` diff --git a/mobile/src/main/java/be/mygod/vpnhotspot/LocalOnlyHotspotService.kt b/mobile/src/main/java/be/mygod/vpnhotspot/LocalOnlyHotspotService.kt index 7c80c71b..97f3f757 100644 --- a/mobile/src/main/java/be/mygod/vpnhotspot/LocalOnlyHotspotService.kt +++ b/mobile/src/main/java/be/mygod/vpnhotspot/LocalOnlyHotspotService.kt @@ -5,14 +5,20 @@ import android.content.IntentFilter import android.net.wifi.WifiManager import android.os.Build 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.root.LocalOnlyHotspotCallbacks +import be.mygod.vpnhotspot.root.RootManager +import be.mygod.vpnhotspot.root.WifiApCommands import be.mygod.vpnhotspot.util.Services import be.mygod.vpnhotspot.util.StickyEvent1 import be.mygod.vpnhotspot.util.broadcastReceiver @@ -23,6 +29,10 @@ import java.net.Inet4Address @RequiresApi(26) class LocalOnlyHotspotService : IpNeighbourMonitoringService(), CoroutineScope { + companion object { + const val KEY_USE_SYSTEM = "service.tempHotspot.useSystem" + } + inner class Binder : android.os.Binder() { /** * null represents IDLE, "" represents CONNECTING, "something" represents CONNECTED. @@ -33,10 +43,7 @@ class LocalOnlyHotspotService : IpNeighbourMonitoringService(), CoroutineScope { ifaceChanged(value) } val ifaceChanged = StickyEvent1 { iface } - - val configuration get() = if (Build.VERSION.SDK_INT < 30) @Suppress("DEPRECATION") { - reservation?.wifiConfiguration?.toCompat() - } else reservation?.softApConfiguration?.toCompat() + val configuration get() = reservation?.configuration fun stop() { when (iface) { @@ -47,8 +54,65 @@ class LocalOnlyHotspotService : IpNeighbourMonitoringService(), CoroutineScope { } } + interface Reservation : AutoCloseable { + val configuration: SoftApConfigurationCompat? + } + class Framework(private val reservation: WifiManager.LocalOnlyHotspotReservation) : Reservation { + override val configuration get() = if (Build.VERSION.SDK_INT < 30) @Suppress("DEPRECATION") { + reservation.wifiConfiguration?.toCompat() + } else reservation.softApConfiguration.toCompat() + override fun close() = reservation.close() + } + @RequiresApi(30) + inner class Root(rootServer: RootServer) : Reservation { + private val channel = rootServer.create(WifiApCommands.StartLocalOnlyHotspot(), this@LocalOnlyHotspotService) + override var configuration: SoftApConfigurationCompat? = null + private set + override fun close() = channel.cancel() + + suspend fun work() { + for (callback in channel) when (callback) { + is LocalOnlyHotspotCallbacks.OnStarted -> configuration = callback.config.toCompat() + is LocalOnlyHotspotCallbacks.OnStopped -> reservation = null + is LocalOnlyHotspotCallbacks.OnFailed -> onFrameworkFailed(callback.reason) + } + } + } + private val binder = Binder() - private var reservation: WifiManager.LocalOnlyHotspotReservation? = null + private var reservation: Reservation? = null + set(value) { + field = value + if (value != null && !receiverRegistered) { + 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) { + SmartSnackbar.make(getString(R.string.tethering_temp_hotspot_failure, when (reason) { + WifiManager.LocalOnlyHotspotCallback.ERROR_NO_CHANNEL -> { + getString(R.string.tethering_temp_hotspot_failure_no_channel) + } + WifiManager.LocalOnlyHotspotCallback.ERROR_GENERIC -> { + getString(R.string.tethering_temp_hotspot_failure_generic) + } + WifiManager.LocalOnlyHotspotCallback.ERROR_INCOMPATIBLE_MODE -> { + getString(R.string.tethering_temp_hotspot_failure_incompatible_mode) + } + WifiManager.LocalOnlyHotspotCallback.ERROR_TETHERING_DISALLOWED -> { + getString(R.string.tethering_temp_hotspot_failure_tethering_disallowed) + } + else -> getString(R.string.failure_reason_unknown, reason) + })).show() + stopService() + } + /** * Writes and critical reads to routingManager should be protected with this context. */ @@ -82,40 +146,35 @@ class LocalOnlyHotspotService : IpNeighbourMonitoringService(), CoroutineScope { if (binder.iface != null) return START_STICKY binder.iface = "" updateNotification() // show invisible foreground notification to avoid being killed + launch(start = CoroutineStart.UNDISPATCHED) { doStart() } + return START_STICKY + } + private suspend fun doStart() { + if (Build.VERSION.SDK_INT >= 30 && app.pref.getBoolean(KEY_USE_SYSTEM, false)) try { + RootManager.use { + Root(it).apply { + reservation = this + work() + } + } + return + } catch (_: CancellationException) { + return + } catch (e: Exception) { + Timber.w(e) + SmartSnackbar.make(e).show() + } try { Services.wifi.startLocalOnlyHotspot(object : WifiManager.LocalOnlyHotspotCallback() { override fun onStarted(reservation: WifiManager.LocalOnlyHotspotReservation?) { if (reservation == null) onFailed(-2) else { - this@LocalOnlyHotspotService.reservation = reservation - if (!receiverRegistered) { - val configuration = binder.configuration!! - if (Build.VERSION.SDK_INT < 30 && configuration.isAutoShutdownEnabled) { - timeoutMonitor = TetherTimeoutMonitor(configuration.shutdownTimeoutMillis, - coroutineContext) { reservation.close() } - } - registerReceiver(receiver, IntentFilter(TetheringManager.ACTION_TETHER_STATE_CHANGED)) - receiverRegistered = true - } + this@LocalOnlyHotspotService.reservation = Framework(reservation) } } - override fun onStopped() { - Timber.d("LOHCallback.onStopped") reservation = null } - - override fun onFailed(reason: Int) { - SmartSnackbar.make(getString(R.string.tethering_temp_hotspot_failure, when (reason) { - ERROR_NO_CHANNEL -> getString(R.string.tethering_temp_hotspot_failure_no_channel) - ERROR_GENERIC -> getString(R.string.tethering_temp_hotspot_failure_generic) - ERROR_INCOMPATIBLE_MODE -> getString(R.string.tethering_temp_hotspot_failure_incompatible_mode) - ERROR_TETHERING_DISALLOWED -> { - getString(R.string.tethering_temp_hotspot_failure_tethering_disallowed) - } - else -> getString(R.string.failure_reason_unknown, reason) - })).show() - stopService() - } + override fun onFailed(reason: Int) = onFrameworkFailed(reason) }, null) } catch (e: IllegalStateException) { // throws IllegalStateException if the caller attempts to start the LocalOnlyHotspot while they @@ -128,7 +187,6 @@ class LocalOnlyHotspotService : IpNeighbourMonitoringService(), CoroutineScope { SmartSnackbar.make(e).show() stopService() } - return START_STICKY } override fun onIpNeighbourAvailable(neighbours: Collection) { diff --git a/mobile/src/main/java/be/mygod/vpnhotspot/SettingsPreferenceFragment.kt b/mobile/src/main/java/be/mygod/vpnhotspot/SettingsPreferenceFragment.kt index 5d3372b0..1997cd49 100644 --- a/mobile/src/main/java/be/mygod/vpnhotspot/SettingsPreferenceFragment.kt +++ b/mobile/src/main/java/be/mygod/vpnhotspot/SettingsPreferenceFragment.kt @@ -1,5 +1,6 @@ package be.mygod.vpnhotspot +import android.annotation.TargetApi import android.content.Intent import android.os.Build import android.os.Bundle @@ -38,6 +39,8 @@ import java.io.PrintWriter 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 @@ -65,7 +68,7 @@ class SettingsPreferenceFragment : PreferenceFragmentCompat() { } false } - } else parent!!.removePreference(this) + } else remove() } val boot = findPreference("service.repeater.startOnBoot")!! if (Services.p2p != null) { @@ -74,11 +77,12 @@ class SettingsPreferenceFragment : PreferenceFragmentCompat() { true } boot.isChecked = BootReceiver.enabled - } else boot.parent!!.removePreference(boot) + } else boot.remove() if (Services.p2p == null || !RepeaterService.safeModeConfigurable) { val safeMode = findPreference(RepeaterService.KEY_SAFE_MODE)!! - safeMode.parent!!.removePreference(safeMode) + safeMode.remove() } + if (Build.VERSION.SDK_INT < 30) findPreference(LocalOnlyHotspotService.KEY_USE_SYSTEM)!!.remove() findPreference("service.clean")!!.setOnPreferenceClickListener { GlobalScope.launch { RoutingManager.clean() } true diff --git a/mobile/src/main/java/be/mygod/vpnhotspot/manage/TetheringFragment.kt b/mobile/src/main/java/be/mygod/vpnhotspot/manage/TetheringFragment.kt index 25be6bb0..fb2f2506 100644 --- a/mobile/src/main/java/be/mygod/vpnhotspot/manage/TetheringFragment.kt +++ b/mobile/src/main/java/be/mygod/vpnhotspot/manage/TetheringFragment.kt @@ -191,7 +191,7 @@ class TetheringFragment : Fragment(), ServiceConnection, Toolbar.OnMenuItemClick apConfigurationRunning = true viewLifecycleOwner.lifecycleScope.launchWhenCreated { try { - WifiApManager.configuration + WifiApManager.configurationCompat } catch (e: InvocationTargetException) { if (e.targetException !is SecurityException) Timber.w(e) try { @@ -237,7 +237,7 @@ class TetheringFragment : Fragment(), ServiceConnection, Toolbar.OnMenuItemClick SmartSnackbar.make(e).show() } val success = try { - WifiApManager.setConfiguration(configuration) + WifiApManager.setConfigurationCompat(configuration) } catch (e: InvocationTargetException) { try { RootManager.use { it.execute(WifiApCommands.SetConfiguration(configuration)) } diff --git a/mobile/src/main/java/be/mygod/vpnhotspot/net/wifi/WifiApManager.kt b/mobile/src/main/java/be/mygod/vpnhotspot/net/wifi/WifiApManager.kt index b3ef3aa2..847ba9f6 100644 --- a/mobile/src/main/java/be/mygod/vpnhotspot/net/wifi/WifiApManager.kt +++ b/mobile/src/main/java/be/mygod/vpnhotspot/net/wifi/WifiApManager.kt @@ -72,16 +72,20 @@ 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 configuration get() = if (Build.VERSION.SDK_INT < 30) @Suppress("DEPRECATION") { + val configurationCompat get() = if (Build.VERSION.SDK_INT < 30) @Suppress("DEPRECATION") { (getWifiApConfiguration(Services.wifi) as android.net.wifi.WifiConfiguration?)?.toCompat() ?: SoftApConfigurationCompat() - } else (getSoftApConfiguration(Services.wifi) as SoftApConfiguration).toCompat() - fun setConfiguration(value: SoftApConfigurationCompat) = (if (Build.VERSION.SDK_INT < 30) @Suppress("DEPRECATION") { + } 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()) - } else setSoftApConfiguration(Services.wifi, value.toPlatform())) as Boolean + }) as Boolean @RequiresApi(28) interface SoftApCallbackCompat { @@ -236,6 +240,16 @@ object WifiApManager { @RequiresApi(28) fun unregisterSoftApCallback(key: Any) = unregisterSoftApCallback(Services.wifi, key) + @get:RequiresApi(30) + private val startLocalOnlyHotspot by lazy @TargetApi(30) { + WifiManager::class.java.getDeclaredMethod("startLocalOnlyHotspot", SoftApConfiguration::class.java, + Executor::class.java, WifiManager.LocalOnlyHotspotCallback::class.java) + } + @RequiresApi(30) + fun startLocalOnlyHotspot(config: SoftApConfiguration, callback: WifiManager.LocalOnlyHotspotCallback?, + executor: Executor? = null) = + startLocalOnlyHotspot(Services.wifi, config, executor, callback) + private val cancelLocalOnlyHotspotRequest by lazy { WifiManager::class.java.getDeclaredMethod("cancelLocalOnlyHotspotRequest") } diff --git a/mobile/src/main/java/be/mygod/vpnhotspot/root/LocalOnlyHotspotCallbacks.kt b/mobile/src/main/java/be/mygod/vpnhotspot/root/LocalOnlyHotspotCallbacks.kt new file mode 100644 index 00000000..ef73a04e --- /dev/null +++ b/mobile/src/main/java/be/mygod/vpnhotspot/root/LocalOnlyHotspotCallbacks.kt @@ -0,0 +1,19 @@ +package be.mygod.vpnhotspot.root + +import android.net.wifi.SoftApConfiguration +import android.os.Parcelable +import androidx.annotation.RequiresApi +import kotlinx.parcelize.Parcelize + +@RequiresApi(30) +sealed class LocalOnlyHotspotCallbacks : Parcelable { + @Parcelize + data class OnStarted(val config: SoftApConfiguration) : LocalOnlyHotspotCallbacks() + @Parcelize + class OnStopped : LocalOnlyHotspotCallbacks() { + override fun equals(other: Any?) = other is OnStopped + override fun hashCode() = 0x80acd3ca.toInt() + } + @Parcelize + data class OnFailed(val reason: Int) : LocalOnlyHotspotCallbacks() +} diff --git a/mobile/src/main/java/be/mygod/vpnhotspot/root/WifiApCommands.kt b/mobile/src/main/java/be/mygod/vpnhotspot/root/WifiApCommands.kt index bea331cd..0b6b6794 100644 --- a/mobile/src/main/java/be/mygod/vpnhotspot/root/WifiApCommands.kt +++ b/mobile/src/main/java/be/mygod/vpnhotspot/root/WifiApCommands.kt @@ -1,5 +1,6 @@ package be.mygod.vpnhotspot.root +import android.net.wifi.WifiManager import android.os.Parcelable import androidx.annotation.RequiresApi import be.mygod.librootkotlinx.ParcelableBoolean @@ -62,6 +63,7 @@ object WifiApCommands { private fun push(parcel: SoftApCallbackParcel) { trySend(parcel).onClosed { finish.completeExceptionally(it ?: ClosedSendChannelException("Channel was closed normally")) + return }.onFailure { throw it!! } } @@ -149,11 +151,60 @@ object WifiApCommands { @Parcelize class GetConfiguration : RootCommand { - override suspend fun execute() = WifiApManager.configuration + override suspend fun execute() = WifiApManager.configurationCompat } @Parcelize data class SetConfiguration(val configuration: SoftApConfigurationCompat) : RootCommand { - override suspend fun execute() = ParcelableBoolean(WifiApManager.setConfiguration(configuration)) + override suspend fun execute() = ParcelableBoolean(WifiApManager.setConfigurationCompat(configuration)) + } + + @Parcelize + @RequiresApi(30) + class StartLocalOnlyHotspot : RootCommandChannel { + override fun create(scope: CoroutineScope) = scope.produce(capacity = capacity) { + val finish = CompletableDeferred() + var lohr: WifiManager.LocalOnlyHotspotReservation? = null + WifiApManager.startLocalOnlyHotspot(WifiApManager.configuration, object : + WifiManager.LocalOnlyHotspotCallback() { + private fun push(parcel: LocalOnlyHotspotCallbacks) { + trySend(parcel).onClosed { + finish.completeExceptionally(it ?: ClosedSendChannelException("Channel was closed normally")) + return + }.onFailure { throw it!! } + } + override fun onStarted(reservation: WifiManager.LocalOnlyHotspotReservation?) { + if (reservation == null) onFailed(-3) else { + require(lohr == null) + lohr = reservation + push(LocalOnlyHotspotCallbacks.OnStarted(reservation.softApConfiguration)) + } + } + override fun onStopped() { + push(LocalOnlyHotspotCallbacks.OnStopped()) + finish.complete(Unit) + } + override fun onFailed(reason: Int) { + push(LocalOnlyHotspotCallbacks.OnFailed(reason)) + finish.complete(Unit) + } + }) { + scope.launch { + try { + it.run() + } catch (e: Throwable) { + finish.completeExceptionally(e) + } + } + } + try { + finish.await() + } catch (e: Exception) { + WifiApManager.cancelLocalOnlyHotspotRequest() + throw e + } finally { + lohr?.close() + } + } } } diff --git a/mobile/src/main/res/drawable/ic_content_file_copy.xml b/mobile/src/main/res/drawable/ic_content_file_copy.xml new file mode 100644 index 00000000..bb623caa --- /dev/null +++ b/mobile/src/main/res/drawable/ic_content_file_copy.xml @@ -0,0 +1,5 @@ + + + diff --git a/mobile/src/main/res/values-zh-rCN/strings.xml b/mobile/src/main/res/values-zh-rCN/strings.xml index 69f3e8fc..0cc49c35 100644 --- a/mobile/src/main/res/values-zh-rCN/strings.xml +++ b/mobile/src/main/res/values-zh-rCN/strings.xml @@ -113,6 +113,8 @@ 中继安全模式 不对系统配置进行修改,但是可能须要较长的网络名称。 使用短名称可能需要关闭安全模式。 + 临时 WLAN 热点使用系统配置 + 这将与其他使用本地热点的应用冲突 保持 Wi\u2011Fi 开启 系统默认 diff --git a/mobile/src/main/res/values/strings.xml b/mobile/src/main/res/values/strings.xml index c4ce4b36..692f58ad 100644 --- a/mobile/src/main/res/values/strings.xml +++ b/mobile/src/main/res/values/strings.xml @@ -131,6 +131,9 @@ not work with short network names. Short network names might require turning off safe mode. + Use system configuration for temporary hotspot + Will conflict with other apps using local only + hotspot Keep Wi\u2011Fi alive System default On diff --git a/mobile/src/main/res/xml/pref_settings.xml b/mobile/src/main/res/xml/pref_settings.xml index 2f047ab9..0cc8b2bd 100644 --- a/mobile/src/main/res/xml/pref_settings.xml +++ b/mobile/src/main/res/xml/pref_settings.xml @@ -68,6 +68,11 @@ app:title="@string/settings_service_repeater_safe_mode" app:summary="@string/settings_service_repeater_safe_mode_summary" app:defaultValue="true"/> +