Skip to content

Commit cd88fea

Browse files
committed
Add hotspot on notification command
- Introduced HotspotHelper class for managing Wi-Fi hotspot operations on Android 11 (API 30) using TetheringManager - Integrated hotspot commands into MessagingManager, allowing for enabling and disabling the hotspot via notifications. - Added a new string resource for missing write settings permission notification.
1 parent 407df5c commit cd88fea

File tree

3 files changed

+245
-1
lines changed

3 files changed

+245
-1
lines changed

app/src/main/kotlin/io/homeassistant/companion/android/notifications/MessagingManager.kt

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ import io.homeassistant.companion.android.sensors.NotificationSensorManager
7878
import io.homeassistant.companion.android.sensors.SensorReceiver
7979
import io.homeassistant.companion.android.settings.SettingsActivity
8080
import io.homeassistant.companion.android.util.FlashlightHelper
81+
import io.homeassistant.companion.android.util.HotspotHelper
8182
import io.homeassistant.companion.android.util.PermissionRequestMediator
8283
import io.homeassistant.companion.android.util.UrlUtil
8384
import io.homeassistant.companion.android.vehicle.HaCarAppService
@@ -115,6 +116,7 @@ class MessagingManager @Inject constructor(
115116
private val settingsDao: SettingsDao,
116117
private val textToSpeechClient: TextToSpeechClient,
117118
private val flashlightHelper: FlashlightHelper,
119+
private val hotspotHelper: HotspotHelper,
118120
private val permissionRequestMediator: PermissionRequestMediator
119121
) {
120122
companion object {
@@ -177,6 +179,7 @@ class MessagingManager @Inject constructor(
177179
const val COMMAND_SCREEN_BRIGHTNESS_LEVEL = "command_screen_brightness_level"
178180
const val COMMAND_SCREEN_OFF_TIMEOUT = "command_screen_off_timeout"
179181
const val COMMAND_FLASHLIGHT = "command_flashlight"
182+
const val COMMAND_HOTSPOT = "command_hotspot"
180183

181184
// DND commands
182185
const val DND_PRIORITY_ONLY = "priority_only"
@@ -232,7 +235,8 @@ class MessagingManager @Inject constructor(
232235
COMMAND_AUTO_SCREEN_BRIGHTNESS,
233236
COMMAND_SCREEN_BRIGHTNESS_LEVEL,
234237
COMMAND_SCREEN_OFF_TIMEOUT,
235-
COMMAND_FLASHLIGHT
238+
COMMAND_FLASHLIGHT,
239+
COMMAND_HOTSPOT
236240
)
237241
val DND_COMMANDS = listOf(DND_ALARMS_ONLY, DND_ALL, DND_NONE, DND_PRIORITY_ONLY)
238242
val RM_COMMANDS = listOf(RM_NORMAL, RM_SILENT, RM_VIBRATE)
@@ -546,6 +550,15 @@ class MessagingManager @Inject constructor(
546550
sendNotification(jsonData)
547551
}
548552
}
553+
COMMAND_HOTSPOT -> {
554+
val command = jsonData[NotificationData.COMMAND]
555+
if (command in DeviceCommandData.ENABLE_COMMANDS && Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
556+
handleDeviceCommands(jsonData)
557+
} else {
558+
Timber.d("Invalid hotspot command received, posting notification to device")
559+
sendNotification(jsonData)
560+
}
561+
}
549562
else -> Timber.d("No command received")
550563
}
551564
}
@@ -802,6 +815,22 @@ class MessagingManager @Inject constructor(
802815
requestCameraPermission()
803816
}
804817
}
818+
COMMAND_HOTSPOT -> {
819+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
820+
if (Settings.System.canWrite(context)) {
821+
// API 30+ AND PERMISSION GRANTED
822+
when (command) {
823+
DeviceCommandData.TURN_OFF -> hotspotHelper.turnOffHotspot()
824+
DeviceCommandData.TURN_ON -> hotspotHelper.turnOnHotspot()
825+
}
826+
} else {
827+
Handler(Looper.getMainLooper()).post {
828+
Toast.makeText(context, commonR.string.missing_write_settings_permission, Toast.LENGTH_LONG).show()
829+
}
830+
requestWriteSystemPermission()
831+
}
832+
}
833+
}
805834
else -> Timber.d("No command received")
806835
}
807836
}
Lines changed: 214 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,214 @@
1+
package io.homeassistant.companion.android.util
2+
3+
import android.content.Context
4+
import android.os.Build
5+
import android.os.Handler
6+
import android.os.Looper
7+
import androidx.annotation.RequiresApi
8+
import dagger.hilt.android.qualifiers.ApplicationContext
9+
import javax.inject.Inject
10+
import timber.log.Timber
11+
import java.util.concurrent.Executor
12+
13+
/**
14+
* Helper class for managing Wi-Fi hotspot functionality.
15+
* Only supported on Android 11 (API 30) and above.
16+
*/
17+
class HotspotHelper @Inject constructor(
18+
@ApplicationContext private val context: Context
19+
) {
20+
companion object {
21+
private const val TETHERING_WIFI = 0
22+
private const val METHOD_TETHERING_MANAGER = "TetheringManager"
23+
// private const val METHOD_CONNECTIVITY_MANAGER = "ConnectivityManager"
24+
}
25+
26+
// Executor implementation to run on the main thread
27+
private val mainExecutor = Executor { command -> Handler(Looper.getMainLooper()).post(command) }
28+
29+
/**
30+
* Enables the Wi-Fi hotspot.
31+
* Only works on Android 11+.
32+
*
33+
* @return true if operation was successful
34+
*/
35+
fun turnOnHotspot(): Boolean {
36+
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) {
37+
Timber.i("Hotspot control not supported on Android versions below 11 (API 30)")
38+
return false
39+
}
40+
41+
try {
42+
return enableHotspot()
43+
} catch (e: Exception) {
44+
Timber.e(e, "Failed to turn on hotspot")
45+
return false
46+
}
47+
}
48+
49+
/**
50+
* Disables the Wi-Fi hotspot.
51+
* Only works on Android 11+.
52+
*
53+
* @return true if operation was successful
54+
*/
55+
fun turnOffHotspot(): Boolean {
56+
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) {
57+
Timber.i("Hotspot control not supported on Android versions below 11 (API 30)")
58+
return false
59+
}
60+
61+
try {
62+
return disableHotspot()
63+
} catch (e: Exception) {
64+
Timber.e(e, "Failed to turn off hotspot")
65+
return false
66+
}
67+
}
68+
69+
// API 30+ needed for TetheringManager
70+
@RequiresApi(Build.VERSION_CODES.R)
71+
private fun enableHotspot(): Boolean {
72+
// Try TetheringManager
73+
// Support least Android 11(API 30) Code.R
74+
if (enableHotspotWithTetheringManager()) {
75+
Timber.i("Hotspot enabled successfully using $METHOD_TETHERING_MANAGER")
76+
return true
77+
}
78+
79+
// TODO: implement using ConnectivityManager as fallback
80+
81+
Timber.e("Failed to enable hotspot: no viable method found")
82+
return false
83+
}
84+
85+
@RequiresApi(Build.VERSION_CODES.R)
86+
private fun disableHotspot(): Boolean {
87+
// Try TetheringManager
88+
// Support least Android 11(API 30) Code.R
89+
if (disableHotspotWithTetheringManager()) {
90+
Timber.i("Hotspot disabled successfully using $METHOD_TETHERING_MANAGER")
91+
return true
92+
}
93+
94+
// TODO: implement using ConnectivityManager as fallback
95+
96+
Timber.e("Failed to disable hotspot: no viable method found")
97+
return false
98+
}
99+
100+
/**
101+
* Attempts to enable hotspot using TetheringManager via reflection.
102+
*/
103+
@RequiresApi(Build.VERSION_CODES.R)
104+
private fun enableHotspotWithTetheringManager(): Boolean {
105+
try {
106+
val tetheringManagerClass = Class.forName("android.net.TetheringManager")
107+
val tetheringManager = context.getSystemService(tetheringManagerClass) ?: return false
108+
109+
// Look for appropriate method
110+
for (method in tetheringManager.javaClass.methods) {
111+
if (method.name == "startTethering") {
112+
try {
113+
val callbackClass = Class.forName("android.net.TetheringManager\$StartTetheringCallback")
114+
115+
// Try with TetheringRequest (preferred on newer devices)
116+
val tetheringRequestClass = Class.forName("android.net.TetheringManager\$TetheringRequest")
117+
val builderClass = Class.forName("android.net.TetheringManager\$TetheringRequest\$Builder")
118+
val builder = builderClass.getConstructor(Int::class.java).newInstance(TETHERING_WIFI)
119+
val request = builderClass.getMethod("build").invoke(builder)
120+
121+
val startMethod = tetheringManager.javaClass.getMethod(
122+
"startTethering",
123+
tetheringRequestClass,
124+
Executor::class.java,
125+
callbackClass
126+
)
127+
128+
val callbackInstance = createCallbackInstance(callbackClass)
129+
130+
Timber.d("Enabling hotspot with TetheringRequest approach")
131+
startMethod.invoke(tetheringManager, request, mainExecutor, callbackInstance)
132+
return true
133+
}
134+
catch (e: Exception) {
135+
Timber.e(e, "Error invoking TetheringManager.startTethering")
136+
}
137+
}
138+
}
139+
Timber.w("No suitable TetheringManager.startTethering method found")
140+
return false
141+
} catch (e: Exception) {
142+
Timber.e(e, "Error accessing TetheringManager")
143+
return false
144+
}
145+
}
146+
147+
/**
148+
* Creates appropriate callback instance through reflection.
149+
* Handles both interface and abstract class scenarios.
150+
*/
151+
private fun createCallbackInstance(callbackClass: Class<*>): Any {
152+
try {
153+
if (callbackClass.isInterface) {
154+
// For interfaces, use Proxy
155+
return java.lang.reflect.Proxy.newProxyInstance(
156+
callbackClass.classLoader,
157+
arrayOf(callbackClass)
158+
) { _, method, _ ->
159+
when (method.name) {
160+
"onTetheringStarted" -> {
161+
Timber.d("Callback: Tethering started successfully")
162+
null
163+
}
164+
// this also causes when tethering already started
165+
"onTetheringFailed" -> {
166+
Timber.e("Callback: Tethering failed to start")
167+
null
168+
}
169+
else -> null
170+
}
171+
}
172+
} else {
173+
// For abstract classes, create instance via constructor
174+
val constructor = callbackClass.getDeclaredConstructor()
175+
constructor.isAccessible = true
176+
return constructor.newInstance()
177+
}
178+
} catch (e: Exception) {
179+
Timber.e(e, "Failed to create callback instance")
180+
throw e
181+
}
182+
}
183+
184+
/**
185+
* Attempts to disable hotspot using TetheringManager via reflection.
186+
*/
187+
@RequiresApi(Build.VERSION_CODES.R)
188+
private fun disableHotspotWithTetheringManager(): Boolean {
189+
try {
190+
val tetheringManagerClass = Class.forName("android.net.TetheringManager")
191+
val tetheringManager = context.getSystemService(tetheringManagerClass) ?: return false
192+
193+
// Look for stopTethering method
194+
for (method in tetheringManager.javaClass.methods) {
195+
if (method.name == "stopTethering" &&
196+
method.parameterTypes.size == 1 &&
197+
method.parameterTypes[0] == Int::class.java) {
198+
try {
199+
Timber.d("Disabling hotspot with TetheringManager.stopTethering")
200+
method.invoke(tetheringManager, TETHERING_WIFI)
201+
return true
202+
} catch (e: Exception) {
203+
Timber.e(e, "Error invoking TetheringManager.stopTethering")
204+
}
205+
}
206+
}
207+
Timber.w("No suitable TetheringManager.stopTethering method found")
208+
return false
209+
} catch (e: Exception) {
210+
Timber.e(e, "Error accessing TetheringManager")
211+
return false
212+
}
213+
}
214+
}

common/src/main/res/values/strings.xml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1129,6 +1129,7 @@
11291129
<string name="sensor_update_type_info_location">For more information about location updates, please check the documentation.</string>
11301130
<string name="sensor_update_type_info_custom">This sensor updates on a non-standard schedule, please check the documentation.</string>
11311131
<string name="missing_camera_permission">\'Camera\' permission required</string>
1132+
<string name="missing_write_settings_permission">Please grant permission to modify system settings</string>
11321133
<string name="missing_phone_permission">Please grant Phone permission to make a phone call</string>
11331134
<string name="missing_bluetooth_permission">Please grant Nearby devices permission to control Bluetooth</string>
11341135
<string name="notification_channels_description">Notification channels allow you to control the look and feel of a certain type of notification. You can create notification channels by sending a notification with the channel parameter, refer to the help icon in the upper right hand corner to learn more. Below is a list of all notification channels created by your server and the app. Select the edit icon to adjust channel settings like the notification sound and Do Not Disturb override. Select the delete icon to remove the channel from this list.\n\nDeleting a channel does not reset channel settings, if a notification is received with the same channel name then it will re-use its previous settings. You can only delete channels created by your server.</string>

0 commit comments

Comments
 (0)