Skip to content

Commit 9c4910a

Browse files
committed
#1491 feat: action to toggle/enable/disable hotspot
1 parent 051ecec commit 9c4910a

19 files changed

Lines changed: 233 additions & 30 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
## Added
66
- #1871 action to modify any system settings
77
- #1221 action to show a custom notification
8+
- #1491 action to toggle/enable/disable hotspot
89

910
## [4.0.0 Beta 2](https://github.com/sds100/KeyMapper/releases/tag/v4.0.0-beta.02)
1011

app/proguard-rules.pro

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,9 @@
7777
-keep class io.github.sds100.keymapper.api.IKeyEventRelayServiceCallback$Stub { *; }
7878
-keep class com.android.internal.telephony.ITelephony { *; }
7979
-keep class com.android.internal.telephony.ITelephony$Stub { *; }
80+
-keep class android.net.ITetheringConnector { *; }
81+
-keep class android.net.ITetheringConnector$Stub { *; }
82+
-keep class android.net.* { *; }
8083

8184
-keepattributes *Annotation*, InnerClasses
8285
-dontnote kotlinx.serialization.AnnotationsKt # core serialization annotations

base/src/main/assets/whats-new.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ You can now remap ALL buttons when the screen is off (including the power button
88
• Mute/unmute microphone
99
• Modify any system setting
1010
• Show a custom notification
11+
• Toggle hotspot
1112

1213
🆕 New Features
1314
• Redesigned Settings screen

base/src/main/java/io/github/sds100/keymapper/base/actions/ActionUiHelper.kt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -507,6 +507,10 @@ class ActionUiHelper(
507507
ActionData.MobileData.Enable -> getString(R.string.action_enable_mobile_data)
508508
ActionData.MobileData.Toggle -> getString(R.string.action_toggle_mobile_data)
509509

510+
ActionData.Hotspot.Disable -> getString(R.string.action_disable_hotspot)
511+
ActionData.Hotspot.Enable -> getString(R.string.action_enable_hotspot)
512+
ActionData.Hotspot.Toggle -> getString(R.string.action_toggle_hotspot)
513+
510514
is ActionData.MoveCursor -> {
511515
when (action.direction) {
512516
ActionData.MoveCursor.Direction.START -> {

base/src/main/java/io/github/sds100/keymapper/base/actions/ActionUtils.kt

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,8 @@ import androidx.compose.material.icons.outlined.Swipe
6767
import androidx.compose.material.icons.outlined.TouchApp
6868
import androidx.compose.material.icons.outlined.VerticalSplit
6969
import androidx.compose.material.icons.outlined.ViewArray
70+
import androidx.compose.material.icons.outlined.WifiTethering
71+
import androidx.compose.material.icons.outlined.WifiTetheringOff
7072
import androidx.compose.material.icons.rounded.Abc
7173
import androidx.compose.material.icons.rounded.Android
7274
import androidx.compose.material.icons.rounded.Bluetooth
@@ -143,6 +145,10 @@ object ActionUtils {
143145
ActionId.ENABLE_MOBILE_DATA -> ActionCategory.CONNECTIVITY
144146
ActionId.DISABLE_MOBILE_DATA -> ActionCategory.CONNECTIVITY
145147

148+
ActionId.TOGGLE_HOTSPOT -> ActionCategory.CONNECTIVITY
149+
ActionId.ENABLE_HOTSPOT -> ActionCategory.CONNECTIVITY
150+
ActionId.DISABLE_HOTSPOT -> ActionCategory.CONNECTIVITY
151+
146152
ActionId.TOGGLE_AUTO_BRIGHTNESS -> ActionCategory.DISPLAY
147153
ActionId.DISABLE_AUTO_BRIGHTNESS -> ActionCategory.DISPLAY
148154
ActionId.ENABLE_AUTO_BRIGHTNESS -> ActionCategory.DISPLAY
@@ -388,6 +394,9 @@ object ActionUtils {
388394
ActionId.CLEAR_RECENT_APP -> R.string.action_clear_recent_app
389395

390396
ActionId.MODIFY_SETTING -> R.string.action_modify_setting
397+
ActionId.TOGGLE_HOTSPOT -> R.string.action_toggle_hotspot
398+
ActionId.ENABLE_HOTSPOT -> R.string.action_enable_hotspot
399+
ActionId.DISABLE_HOTSPOT -> R.string.action_disable_hotspot
391400
}
392401

393402
@DrawableRes
@@ -555,6 +564,13 @@ object ActionUtils {
555564
ActionId.SHOW_POWER_MENU -> Build.VERSION_CODES.LOLLIPOP
556565
ActionId.DEVICE_CONTROLS -> Build.VERSION_CODES.S
557566

567+
// It could be supported on older versions but system bridge min API is Q and its extra
568+
// maintenance effort to support the older tethering system API.
569+
ActionId.TOGGLE_HOTSPOT,
570+
ActionId.ENABLE_HOTSPOT,
571+
ActionId.DISABLE_HOTSPOT,
572+
-> Build.VERSION_CODES.R
573+
558574
else -> Constants.MIN_API
559575
}
560576

@@ -620,6 +636,11 @@ object ActionUtils {
620636
ActionId.DISABLE_MOBILE_DATA,
621637
-> true
622638

639+
ActionId.TOGGLE_HOTSPOT,
640+
ActionId.ENABLE_HOTSPOT,
641+
ActionId.DISABLE_HOTSPOT,
642+
-> true
643+
623644
ActionId.ENABLE_NFC,
624645
ActionId.DISABLE_NFC,
625646
ActionId.TOGGLE_NFC,
@@ -904,6 +925,9 @@ object ActionUtils {
904925
ActionId.CLEAR_RECENT_APP -> Icons.Outlined.VerticalSplit
905926

906927
ActionId.MODIFY_SETTING -> Icons.Outlined.Settings
928+
ActionId.TOGGLE_HOTSPOT -> Icons.Outlined.WifiTethering
929+
ActionId.ENABLE_HOTSPOT -> Icons.Outlined.WifiTethering
930+
ActionId.DISABLE_HOTSPOT -> Icons.Outlined.WifiTetheringOff
907931
}
908932
}
909933

base/src/main/java/io/github/sds100/keymapper/base/actions/PerformActionsUseCase.kt

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -484,10 +484,16 @@ class PerformActionsUseCaseImpl @AssistedInject constructor(
484484
}
485485

486486
is ActionData.Hotspot.Toggle -> {
487-
result = if (networkAdapter.isHotspotEnabled()) {
488-
networkAdapter.disableHotspot()
487+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
488+
result = networkAdapter.isHotspotEnabled().then { isEnabled ->
489+
if (isEnabled) {
490+
networkAdapter.disableHotspot()
491+
} else {
492+
networkAdapter.enableHotspot()
493+
}
494+
}
489495
} else {
490-
networkAdapter.enableHotspot()
496+
result = SdkVersionTooLow(minSdk = Build.VERSION_CODES.R)
491497
}
492498
}
493499

sysbridge/src/main/aidl/io/github/sds100/keymapper/sysbridge/ISystemBridge.aidl

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,5 +43,7 @@ interface ISystemBridge {
4343

4444
void setRingerMode(int ringerMode) = 18;
4545

46-
void setTetheringEnabled(boolean enable) = 19;
46+
boolean isTetheringEnabled() = 19;
47+
48+
void setTetheringEnabled(boolean enable) = 20;
4749
}

sysbridge/src/main/java/io/github/sds100/keymapper/sysbridge/service/SystemBridge.kt

Lines changed: 91 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,14 @@ import android.content.pm.PackageManager
1414
import android.hardware.input.IInputManager
1515
import android.media.IAudioService
1616
import android.net.IConnectivityManager
17+
import android.net.ITetheringConnector
18+
import android.net.ITetheringEventCallback
19+
import android.net.Network
20+
import android.net.TetherStatesParcel
21+
import android.net.TetheredClient
22+
import android.net.TetheringCallbackStartedParcel
23+
import android.net.TetheringConfigurationParcel
24+
import android.net.TetheringRequestParcel
1725
import android.net.wifi.IWifiManager
1826
import android.nfc.INfcAdapter
1927
import android.nfc.NfcAdapterApis
@@ -28,6 +36,7 @@ import android.permission.IPermissionManager
2836
import android.permission.PermissionManagerApis
2937
import android.util.Log
3038
import android.view.InputEvent
39+
import androidx.annotation.RequiresApi
3140
import com.android.internal.telephony.ITelephony
3241
import io.github.sds100.keymapper.common.models.EvdevDeviceHandle
3342
import io.github.sds100.keymapper.common.models.ShellResult
@@ -175,6 +184,7 @@ internal class SystemBridge : ISystemBridge.Stub() {
175184
private val bluetoothManager: IBluetoothManager?
176185
private val nfcAdapter: INfcAdapter?
177186
private val connectivityManager: IConnectivityManager?
187+
private val tetheringConnector: ITetheringConnector?
178188
private val activityManager: IActivityManager
179189
private val activityTaskManager: IActivityTaskManager
180190
private val audioService: IAudioService?
@@ -263,6 +273,14 @@ internal class SystemBridge : ISystemBridge.Stub() {
263273
audioService =
264274
IAudioService.Stub.asInterface(ServiceManager.getService(Context.AUDIO_SERVICE))
265275

276+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
277+
waitSystemService("tethering")
278+
tetheringConnector =
279+
ITetheringConnector.Stub.asInterface(ServiceManager.getService("tethering"))
280+
} else {
281+
tetheringConnector = null
282+
}
283+
266284
val applicationInfo = getKeyMapperPackageInfo()
267285

268286
if (applicationInfo == null) {
@@ -669,21 +687,84 @@ internal class SystemBridge : ISystemBridge.Stub() {
669687
audioService.setRingerModeInternal(ringerMode, processPackageName)
670688
}
671689

690+
@RequiresApi(Build.VERSION_CODES.R)
691+
override fun isTetheringEnabled(): Boolean {
692+
if (tetheringConnector == null) {
693+
throw UnsupportedOperationException("TetheringConnector not supported")
694+
}
695+
696+
val lock = Object()
697+
var result = false
698+
val timeoutMillis = 5000L
699+
700+
val callback = object : ITetheringEventCallback.Stub() {
701+
override fun onCallbackStarted(parcel: TetheringCallbackStartedParcel?) {
702+
if (parcel?.states?.tetheredList != null) {
703+
// Check if any tethering interface is active
704+
result = parcel.states.tetheredList.isNotEmpty()
705+
}
706+
707+
synchronized(lock) {
708+
lock.notify()
709+
}
710+
}
711+
712+
override fun onCallbackStopped(errorCode: Int) {}
713+
override fun onUpstreamChanged(network: Network?) {}
714+
override fun onConfigurationChanged(config: TetheringConfigurationParcel?) {}
715+
override fun onTetherStatesChanged(states: TetherStatesParcel?) {}
716+
override fun onTetherClientsChanged(clients: List<TetheredClient?>?) {}
717+
override fun onOffloadStatusChanged(status: Int) {}
718+
override fun onSupportedTetheringTypes(supportedBitmap: Long) {}
719+
}
720+
721+
try {
722+
// We register and immediately unregister the callback after getting the state
723+
// instead of keeping it registered for the lifetime of SystemBridge. This is
724+
// a safety measure in case there's a bug in the callback that could crash
725+
// the entire SystemBridge process.
726+
tetheringConnector.registerTetheringEventCallback(callback, processPackageName)
727+
728+
// Wait for callback with timeout using Handler
729+
mainHandler.postDelayed({
730+
synchronized(lock) {
731+
lock.notify()
732+
}
733+
}, timeoutMillis)
734+
735+
synchronized(lock) {
736+
lock.wait(timeoutMillis)
737+
}
738+
} catch (e: InterruptedException) {
739+
Thread.currentThread().interrupt()
740+
} finally {
741+
tetheringConnector.unregisterTetheringEventCallback(callback, processPackageName)
742+
}
743+
744+
return result
745+
}
746+
747+
@RequiresApi(Build.VERSION_CODES.R)
672748
override fun setTetheringEnabled(enable: Boolean) {
673-
if (connectivityManager == null) {
674-
throw UnsupportedOperationException("ConnectivityManager not supported")
749+
if (tetheringConnector == null) {
750+
throw UnsupportedOperationException("TetheringConnector not supported")
675751
}
676752

677753
if (enable) {
678-
connectivityManager.startTethering(
679-
TETHERING_WIFI,
680-
null, // ResultReceiver
681-
false, // showProvisioningUi
682-
processPackageName, // callerPkg
683-
)
754+
val request = TetheringRequestParcel().apply {
755+
// TetheringManager.TETHERING_WIFI
756+
tetheringType = TETHERING_WIFI
757+
localIPv4Address = null
758+
staticClientAddress = null
759+
exemptFromEntitlementCheck = false
760+
showProvisioningUi = true
761+
// TetheringManager.CONNECTIVITY_SCOPE_GLOBAL
762+
connectivityScope = 1
763+
}
764+
765+
tetheringConnector.startTethering(request, processPackageName, null, null)
684766
} else {
685-
connectivityManager.stopTethering(TETHERING_WIFI)
767+
tetheringConnector.stopTethering(TETHERING_WIFI, processPackageName, null, null)
686768
}
687769
}
688-
}
689770
}

system/src/main/java/io/github/sds100/keymapper/system/network/AndroidNetworkAdapter.kt

Lines changed: 10 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -199,27 +199,22 @@ class AndroidNetworkAdapter @Inject constructor(
199199
}
200200
}
201201

202-
override fun isHotspotEnabled(): Boolean {
203-
// TODO: Implement hotspot state detection using reflection or system bridge.
204-
// For now, returning false means toggle action will always attempt to enable.
205-
// This is acceptable for the initial implementation.
206-
return false
202+
@RequiresApi(Build.VERSION_CODES.R)
203+
override suspend fun isHotspotEnabled(): KMResult<Boolean> {
204+
// isTetheringEnabled is a blocking call that registers a callback
205+
return withContext(Dispatchers.IO) {
206+
systemBridgeConnManager.run { systemBridge -> systemBridge.isTetheringEnabled }
207+
}
207208
}
208209

210+
@RequiresApi(Build.VERSION_CODES.R)
209211
override fun enableHotspot(): KMResult<*> {
210-
if (Build.VERSION.SDK_INT >= Constants.SYSTEM_BRIDGE_MIN_API) {
211-
return systemBridgeConnManager.run { bridge -> bridge.setTetheringEnabled(true) }
212-
} else {
213-
return KMError.FeatureUnavailable
214-
}
212+
return systemBridgeConnManager.run { bridge -> bridge.setTetheringEnabled(true) }
215213
}
216214

215+
@RequiresApi(Build.VERSION_CODES.R)
217216
override fun disableHotspot(): KMResult<*> {
218-
if (Build.VERSION.SDK_INT >= Constants.SYSTEM_BRIDGE_MIN_API) {
219-
return systemBridgeConnManager.run { bridge -> bridge.setTetheringEnabled(false) }
220-
} else {
221-
return KMError.FeatureUnavailable
222-
}
217+
return systemBridgeConnManager.run { bridge -> bridge.setTetheringEnabled(false) }
223218
}
224219

225220
/**

system/src/main/java/io/github/sds100/keymapper/system/network/NetworkAdapter.kt

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
package io.github.sds100.keymapper.system.network
22

3+
import android.os.Build
4+
import androidx.annotation.RequiresApi
35
import io.github.sds100.keymapper.common.utils.KMResult
46
import kotlinx.coroutines.flow.Flow
57

@@ -19,9 +21,13 @@ interface NetworkAdapter {
1921
fun enableMobileData(): KMResult<*>
2022
fun disableMobileData(): KMResult<*>
2123

22-
fun isHotspotEnabled(): Boolean
24+
@RequiresApi(Build.VERSION_CODES.R)
25+
suspend fun isHotspotEnabled(): KMResult<Boolean>
2326

27+
@RequiresApi(Build.VERSION_CODES.R)
2428
fun enableHotspot(): KMResult<*>
29+
30+
@RequiresApi(Build.VERSION_CODES.R)
2531
fun disableHotspot(): KMResult<*>
2632

2733
fun getKnownWifiSSIDs(): List<String>

0 commit comments

Comments
 (0)