From ce8e51f37c0ed5c3749cc998eabec0a7e5baddba Mon Sep 17 00:00:00 2001 From: Isaac Serrano Date: Mon, 12 Oct 2026 16:28:44 -0700 Subject: [PATCH 1/4] feat(island): add hotspot plugin --- .../data/repository/SettingsRepository.kt | 4 + .../domain/registry/FeatureRegistry.kt | 5 + .../essentials/island/model/IslandModels.kt | 1 + .../island/model/IslandPriorityEntries.kt | 1 + .../island/plugins/hotspot/HotspotPlugin.kt | 186 ++++++++++++++++++ .../island/service/IslandCoordinator.kt | 2 + .../ui/features/display/IslandSettingsUI.kt | 15 ++ .../essentials/viewmodels/MainViewModel.kt | 7 + app/src/main/res/values/strings.xml | 6 + 9 files changed, 227 insertions(+) create mode 100644 app/src/main/java/com/sameerasw/essentials/island/plugins/hotspot/HotspotPlugin.kt diff --git a/app/src/main/java/com/sameerasw/essentials/data/repository/SettingsRepository.kt b/app/src/main/java/com/sameerasw/essentials/data/repository/SettingsRepository.kt index 507ae9618..bb73a8aae 100644 --- a/app/src/main/java/com/sameerasw/essentials/data/repository/SettingsRepository.kt +++ b/app/src/main/java/com/sameerasw/essentials/data/repository/SettingsRepository.kt @@ -610,6 +610,7 @@ class SettingsRepository( const val KEY_ISLAND_SHOW_TRAVEL = "island_show_travel" const val KEY_ISLAND_SHOW_CAFFEINATE = "island_show_caffeinate" const val KEY_ISLAND_SHOW_DEVICES = "island_show_devices" + const val KEY_ISLAND_SHOW_HOTSPOT = "island_show_hotspot" const val KEY_ISLAND_BRIEF_ENABLED = "island_brief_enabled" const val KEY_ISLAND_BRIEF_SHOW_ALARM = "island_brief_show_alarm" const val KEY_ISLAND_BRIEF_TWO_LINE_HEADER = "island_brief_two_line_header" @@ -4051,6 +4052,9 @@ class SettingsRepository( fun isIslandShowDevicesEnabled(): Boolean = getBoolean(KEY_ISLAND_SHOW_DEVICES, true) fun setIslandShowDevicesEnabled(enabled: Boolean) = putBoolean(KEY_ISLAND_SHOW_DEVICES, enabled) + fun isIslandShowHotspotEnabled(): Boolean = getBoolean(KEY_ISLAND_SHOW_HOTSPOT, true) + fun setIslandShowHotspotEnabled(enabled: Boolean) = putBoolean(KEY_ISLAND_SHOW_HOTSPOT, enabled) + fun isIslandBriefEnabled(): Boolean = getBoolean(KEY_ISLAND_BRIEF_ENABLED, false) fun setIslandBriefEnabled(enabled: Boolean) = putBoolean(KEY_ISLAND_BRIEF_ENABLED, enabled) diff --git a/app/src/main/java/com/sameerasw/essentials/domain/registry/FeatureRegistry.kt b/app/src/main/java/com/sameerasw/essentials/domain/registry/FeatureRegistry.kt index 61443cc45..a0a8d9d4c 100644 --- a/app/src/main/java/com/sameerasw/essentials/domain/registry/FeatureRegistry.kt +++ b/app/src/main/java/com/sameerasw/essentials/domain/registry/FeatureRegistry.kt @@ -775,6 +775,11 @@ object FeatureRegistry { R.string.island_stopwatch_default, "island_show_timers", ), + SearchSetting( + R.string.island_show_hotspot_title, + R.string.island_hotspot_desc, + "island_show_hotspot", + ), SearchSetting( R.string.feat_caffeinate_title, R.string.caffeinate_notification_desc, diff --git a/app/src/main/java/com/sameerasw/essentials/island/model/IslandModels.kt b/app/src/main/java/com/sameerasw/essentials/island/model/IslandModels.kt index ee2e056fb..e88755a70 100644 --- a/app/src/main/java/com/sameerasw/essentials/island/model/IslandModels.kt +++ b/app/src/main/java/com/sameerasw/essentials/island/model/IslandModels.kt @@ -32,6 +32,7 @@ object IslandPriority { const val SOUND_MODE = 55 const val SIGNAL = 57 const val NETWORK = 60 + const val HOTSPOT = 61 const val DEVICES = 62 const val ALARM = 90 const val DEFAULT = 100 diff --git a/app/src/main/java/com/sameerasw/essentials/island/model/IslandPriorityEntries.kt b/app/src/main/java/com/sameerasw/essentials/island/model/IslandPriorityEntries.kt index 59bd90675..1d9970cea 100644 --- a/app/src/main/java/com/sameerasw/essentials/island/model/IslandPriorityEntries.kt +++ b/app/src/main/java/com/sameerasw/essentials/island/model/IslandPriorityEntries.kt @@ -15,6 +15,7 @@ object IslandPriorityEntries { Entry("calendar", setOf("calendar"), IslandPriority.CALENDAR), Entry("sound_mode", setOf("sound_mode"), IslandPriority.SOUND_MODE), Entry("network", setOf("network", "signal"), IslandPriority.SIGNAL), + Entry("hotspot", setOf("hotspot"), IslandPriority.HOTSPOT), Entry("devices", setOf("devices"), IslandPriority.DEVICES), Entry("alarm", setOf("alarm"), IslandPriority.ALARM), ).sortedBy { it.defaultPriority } diff --git a/app/src/main/java/com/sameerasw/essentials/island/plugins/hotspot/HotspotPlugin.kt b/app/src/main/java/com/sameerasw/essentials/island/plugins/hotspot/HotspotPlugin.kt new file mode 100644 index 000000000..81614f2d3 --- /dev/null +++ b/app/src/main/java/com/sameerasw/essentials/island/plugins/hotspot/HotspotPlugin.kt @@ -0,0 +1,186 @@ +package com.sameerasw.essentials.island.plugins.hotspot + +import android.app.usage.NetworkStats +import android.app.usage.NetworkStatsManager +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.net.ConnectivityManager +import android.net.wifi.WifiManager +import android.text.format.Formatter +import androidx.compose.material3.MaterialTheme +import androidx.compose.ui.unit.dp +import com.sameerasw.essentials.R +import com.sameerasw.essentials.data.repository.SettingsRepository +import com.sameerasw.essentials.island.model.CompactCell +import com.sameerasw.essentials.island.model.CompactPlacement +import com.sameerasw.essentials.island.model.IslandItem +import com.sameerasw.essentials.island.model.IslandPriority +import com.sameerasw.essentials.island.model.LineContent +import com.sameerasw.essentials.island.plugins.BaseIslandPlugin +import com.sameerasw.essentials.island.ui.components.IslandIcon +import com.sameerasw.essentials.island.ui.components.RollingText +import com.sameerasw.essentials.utils.PermissionUtils +import com.sameerasw.essentials.utils.ShellUtils +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.lsposed.hiddenapibypass.HiddenApiBypass + +class HotspotPlugin : BaseIslandPlugin() { + override val id = "hotspot" + + override val settingKeys = setOf(SettingsRepository.KEY_ISLAND_SHOW_HOTSPOT) + + private val stats by lazy { context.getSystemService(NetworkStatsManager::class.java) } + private val wifi by lazy { context.applicationContext.getSystemService(Context.WIFI_SERVICE) as WifiManager } + private var enabled = false + private var windowStart = 0L + private var baseline: Long? = null + private var bytes: Long? = null + private var clients: Int? = null + private var pollJob: Job? = null + + private val receiver = object : BroadcastReceiver() { + override fun onReceive(c: Context, intent: Intent) = + setEnabled(intent.getIntExtra(EXTRA_AP_STATE, 0) == AP_STATE_ENABLED) + } + + override fun onStart() { + try { + // Not sticky on recent Android, so this only catches changes; the initial state is read below. + context.registerReceiver(receiver, IntentFilter(ACTION_AP_STATE)) + } catch (_: Exception) { + } + setEnabled(readApEnabled()) + } + + override fun onStop() { + pollJob?.cancel() + pollJob = null + try { + context.unregisterReceiver(receiver) + } catch (_: Exception) { + } + enabled = false + bytes = null + clients = null + } + + override fun refresh() = render() + + private fun setEnabled(on: Boolean) { + val c = ctx ?: return + if (on == enabled) return + enabled = on + pollJob?.cancel() + pollJob = null + bytes = null + clients = null + baseline = null + if (on) { + windowStart = System.currentTimeMillis() - WINDOW_MS + pollJob = c.scope.launch { poll() } + } + render() + } + + private suspend fun poll() { + while (true) { + val total = withContext(Dispatchers.IO) { readTetheredBytes() } + val count = withContext(Dispatchers.IO) { readClientCount() } + if (ctx == null || !enabled) return + if (total != null && baseline == null) baseline = total + bytes = total?.let { (it - (baseline ?: it)).coerceAtLeast(0L) } + clients = count + render() + delay(POLL_MS) + } + } + + private fun readApEnabled(): Boolean = try { + HiddenApiBypass.invoke(WifiManager::class.java, wifi, "isWifiApEnabled") as? Boolean ?: false + } catch (_: Exception) { + false + } + + private fun readClientCount(): Int? { + val c = ctx?.service ?: return null + if (!ShellUtils.isAvailable(c) || !ShellUtils.hasPermission(c)) return null + val dump = ShellUtils.runCommandWithOutput(c, "dumpsys tethering", notifyOnError = false) ?: return null + return TetheringDump.clientCount(dump) + } + + private fun readTetheredBytes(): Long? { + if (!PermissionUtils.hasUsageStatsPermission(context)) return null + return try { + val now = System.currentTimeMillis() + // Mobile covers the usual case; Wi-Fi covers Pixels sharing a Wi-Fi connection. + listOf(ConnectivityManager.TYPE_MOBILE, ConnectivityManager.TYPE_WIFI).sumOf { type -> + stats.queryDetailsForUid(type, null, windowStart, now, NetworkStats.Bucket.UID_TETHERING).use { s -> + val bucket = NetworkStats.Bucket() + var total = 0L + while (s.hasNextBucket()) { + s.getNextBucket(bucket) + total += bucket.rxBytes + bucket.txBytes + } + total + } + } + } catch (_: Exception) { + null + } + } + + private fun render() { + if (ctx == null || !enabled || !settings.isIslandShowHotspotEnabled()) { + publish(null) + return + } + val count = clients + val end = listOfNotNull( + count?.let { context.resources.getQuantityString(R.plurals.island_hotspot_clients, it, it) }, + bytes?.let { Formatter.formatShortFileSize(context, it) }, + ).joinToString(" ยท ") + publish( + IslandItem( + key = KEY, + priority = IslandPriority.HOTSPOT, + placement = CompactPlacement.Dynamic, + compact = listOfNotNull( + count?.let { CompactCell("hotspot.clients") { RollingText(it.toString()) } }, + CompactCell("hotspot.icon") { + IslandIcon(R.drawable.rounded_wifi_tethering_24, size = 18.dp, tint = MaterialTheme.colorScheme.primary) + }, + ), + line = LineContent( + icon = { IslandIcon(R.drawable.rounded_wifi_tethering_24, tint = MaterialTheme.colorScheme.primary) }, + start = context.getString(R.string.island_show_hotspot_title), + end = end, + ), + ) + ) + } + + companion object { + const val KEY = "hotspot" + private const val ACTION_AP_STATE = "android.net.wifi.WIFI_AP_STATE_CHANGED" + private const val EXTRA_AP_STATE = "wifi_state" + private const val AP_STATE_ENABLED = 13 + private const val POLL_MS = 10_000L + private const val WINDOW_MS = 24 * 60 * 60 * 1000L + } +} + +object TetheringDump { + fun clientCount(dump: String): Int? { + val lines = dump.lineSequence().toList() + val header = lines.indexOfFirst { it.trim() == "Client Information:" } + if (header < 0) return null + val body = lines.drop(header + 1).takeWhile { it.trim().startsWith("{") || it.trim() == "" } + return body.sumOf { Regex("client: /").findAll(it).count() } + } +} diff --git a/app/src/main/java/com/sameerasw/essentials/island/service/IslandCoordinator.kt b/app/src/main/java/com/sameerasw/essentials/island/service/IslandCoordinator.kt index 6885c88ea..494ec40d0 100644 --- a/app/src/main/java/com/sameerasw/essentials/island/service/IslandCoordinator.kt +++ b/app/src/main/java/com/sameerasw/essentials/island/service/IslandCoordinator.kt @@ -42,6 +42,7 @@ import com.sameerasw.essentials.island.model.IslandStage import com.sameerasw.essentials.island.plugins.calendar.CalendarPlugin import com.sameerasw.essentials.island.plugins.consciousgate.ConsciousGatePlugin import com.sameerasw.essentials.island.plugins.flashlight.FlashlightPlugin +import com.sameerasw.essentials.island.plugins.hotspot.HotspotPlugin import com.sameerasw.essentials.island.plugins.network.NetworkPlugin import com.sameerasw.essentials.island.plugins.signal.SignalPlugin import com.sameerasw.essentials.island.plugins.devices.DevicesPlugin @@ -141,6 +142,7 @@ class IslandCoordinator( TravelPlugin(), NetworkPlugin(), SignalPlugin(), + HotspotPlugin(), DevicesPlugin(), BriefPlugin(), BatteryAlertsPlugin(), diff --git a/app/src/main/java/com/sameerasw/essentials/ui/features/display/IslandSettingsUI.kt b/app/src/main/java/com/sameerasw/essentials/ui/features/display/IslandSettingsUI.kt index 0444787cf..7fef587a2 100644 --- a/app/src/main/java/com/sameerasw/essentials/ui/features/display/IslandSettingsUI.kt +++ b/app/src/main/java/com/sameerasw/essentials/ui/features/display/IslandSettingsUI.kt @@ -430,6 +430,21 @@ fun IslandSettingsUI( onSettingsClick = { showAlarmOptionsSheet = true }, modifier = Modifier.highlight(highlightSetting == "island_show_alarm"), ) + "hotspot" -> IconToggleItem( + dragHandle = handle, + iconRes = R.drawable.rounded_wifi_tethering_24, + title = stringResource(R.string.island_show_hotspot_title), + description = stringResource(R.string.island_hotspot_desc), + isChecked = viewModel.isIslandShowHotspot.value, + onCheckedChange = { checked -> + HapticUtil.performVirtualKeyHaptic(view) + viewModel.setIslandShowHotspot(checked) + if (checked && !PermissionUtils.hasUsageStatsPermission(context)) { + requestingPermissionsFor = Pair(R.string.island_title, listOf("USAGE_STATS")) + } + }, + modifier = Modifier.highlight(highlightSetting == "island_show_hotspot"), + ) "devices" -> IconToggleItem( dragHandle = handle, iconRes = R.drawable.rounded_bluetooth_24, diff --git a/app/src/main/java/com/sameerasw/essentials/viewmodels/MainViewModel.kt b/app/src/main/java/com/sameerasw/essentials/viewmodels/MainViewModel.kt index 99f679edd..0c3e19018 100644 --- a/app/src/main/java/com/sameerasw/essentials/viewmodels/MainViewModel.kt +++ b/app/src/main/java/com/sameerasw/essentials/viewmodels/MainViewModel.kt @@ -241,6 +241,7 @@ class MainViewModel : ViewModel() { val islandAlarmWindowHours = mutableIntStateOf(12) val isIslandShowTravel = mutableStateOf(true) val isIslandShowCaffeinate = mutableStateOf(true) + val isIslandShowHotspot = mutableStateOf(true) val isIslandShowDevices = mutableStateOf(true) val isIslandBriefEnabled = mutableStateOf(false) val isIslandBriefShowAlarm = mutableStateOf(true) @@ -2406,6 +2407,7 @@ class MainViewModel : ViewModel() { isDuoKeepInFullscreen.value = settingsRepository.isDuoKeepInFullscreenEnabled() isDuoDisableInteractionsInFullscreen.value = settingsRepository.isDuoDisableInteractionsInFullscreenEnabled() isIslandShowCaffeinate.value = settingsRepository.isIslandShowCaffeinateEnabled() + isIslandShowHotspot.value = settingsRepository.isIslandShowHotspotEnabled() isIslandShowDevices.value = settingsRepository.isIslandShowDevicesEnabled() isIslandBriefEnabled.value = settingsRepository.isIslandBriefEnabled() isIslandBriefShowAlarm.value = settingsRepository.isIslandBriefShowAlarmEnabled() @@ -5492,6 +5494,11 @@ class MainViewModel : ViewModel() { settingsRepository.setIslandShowDevicesEnabled(enabled) } + fun setIslandShowHotspot(enabled: Boolean) { + isIslandShowHotspot.value = enabled + settingsRepository.setIslandShowHotspotEnabled(enabled) + } + fun setIslandShowCaffeinate(enabled: Boolean) { isIslandShowCaffeinate.value = enabled settingsRepository.setIslandShowCaffeinateEnabled(enabled) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 42b5f4366..64ecca1b3 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -3157,6 +3157,12 @@ Combine with Island Combine with Duo Devices + Hotspot + Shows while your hotspot is on, with connected devices and data shared + + %d device + %d devices + Connected Disconnected Bluetooth device From 7e3b10e7f531451449c4455fd68a759d9c071423 Mon Sep 17 00:00:00 2001 From: Isaac Serrano Date: Mon, 12 Oct 2026 20:34:26 -0700 Subject: [PATCH 2/4] feat(island): hotspot count only shown when 1+ device connected --- .../essentials/island/plugins/hotspot/HotspotPlugin.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/com/sameerasw/essentials/island/plugins/hotspot/HotspotPlugin.kt b/app/src/main/java/com/sameerasw/essentials/island/plugins/hotspot/HotspotPlugin.kt index 81614f2d3..8bc76ec59 100644 --- a/app/src/main/java/com/sameerasw/essentials/island/plugins/hotspot/HotspotPlugin.kt +++ b/app/src/main/java/com/sameerasw/essentials/island/plugins/hotspot/HotspotPlugin.kt @@ -140,7 +140,7 @@ class HotspotPlugin : BaseIslandPlugin() { publish(null) return } - val count = clients + val count = clients?.takeIf { it > 0 } val end = listOfNotNull( count?.let { context.resources.getQuantityString(R.plurals.island_hotspot_clients, it, it) }, bytes?.let { Formatter.formatShortFileSize(context, it) }, From 95def5ec4d4fb6bd969a9c5456c3ed688e19f9b1 Mon Sep 17 00:00:00 2001 From: sameerasw Date: Tue, 13 Oct 2026 13:07:18 +0530 Subject: [PATCH 3/4] test: add TetheringDumpTest and refactor HotspotPlugin polling synchronization --- .../island/plugins/hotspot/HotspotPlugin.kt | 38 ++++++++++++------- .../essentials/island/TetheringDumpTest.kt | 32 ++++++++++++++++ 2 files changed, 57 insertions(+), 13 deletions(-) create mode 100644 app/src/test/java/com/sameerasw/essentials/island/TetheringDumpTest.kt diff --git a/app/src/main/java/com/sameerasw/essentials/island/plugins/hotspot/HotspotPlugin.kt b/app/src/main/java/com/sameerasw/essentials/island/plugins/hotspot/HotspotPlugin.kt index 8bc76ec59..c90868233 100644 --- a/app/src/main/java/com/sameerasw/essentials/island/plugins/hotspot/HotspotPlugin.kt +++ b/app/src/main/java/com/sameerasw/essentials/island/plugins/hotspot/HotspotPlugin.kt @@ -9,6 +9,7 @@ import android.content.IntentFilter import android.net.ConnectivityManager import android.net.wifi.WifiManager import android.text.format.Formatter +import android.util.Log import androidx.compose.material3.MaterialTheme import androidx.compose.ui.unit.dp import com.sameerasw.essentials.R @@ -70,24 +71,33 @@ class HotspotPlugin : BaseIslandPlugin() { clients = null } - override fun refresh() = render() + override fun refresh() { + syncPolling() + render() + } private fun setEnabled(on: Boolean) { - val c = ctx ?: return - if (on == enabled) return + if (ctx == null || on == enabled) return enabled = on - pollJob?.cancel() - pollJob = null - bytes = null - clients = null - baseline = null - if (on) { - windowStart = System.currentTimeMillis() - WINDOW_MS - pollJob = c.scope.launch { poll() } - } + syncPolling() render() } + private fun syncPolling() { + val c = ctx ?: return + if (!enabled || !settings.isIslandShowHotspotEnabled()) { + pollJob?.cancel() + pollJob = null + bytes = null + clients = null + baseline = null + return + } + if (pollJob?.isActive == true) return + windowStart = System.currentTimeMillis() - WINDOW_MS + pollJob = c.scope.launch { poll() } + } + private suspend fun poll() { while (true) { val total = withContext(Dispatchers.IO) { readTetheredBytes() } @@ -130,7 +140,8 @@ class HotspotPlugin : BaseIslandPlugin() { total } } - } catch (_: Exception) { + } catch (e: Exception) { + Log.d(TAG, "Tethered bytes query failed", e) null } } @@ -167,6 +178,7 @@ class HotspotPlugin : BaseIslandPlugin() { companion object { const val KEY = "hotspot" + private const val TAG = "HotspotPlugin" private const val ACTION_AP_STATE = "android.net.wifi.WIFI_AP_STATE_CHANGED" private const val EXTRA_AP_STATE = "wifi_state" private const val AP_STATE_ENABLED = 13 diff --git a/app/src/test/java/com/sameerasw/essentials/island/TetheringDumpTest.kt b/app/src/test/java/com/sameerasw/essentials/island/TetheringDumpTest.kt new file mode 100644 index 000000000..762ae6ad2 --- /dev/null +++ b/app/src/test/java/com/sameerasw/essentials/island/TetheringDumpTest.kt @@ -0,0 +1,32 @@ +package com.sameerasw.essentials.island + +import com.sameerasw.essentials.island.plugins.hotspot.TetheringDump +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class TetheringDumpTest { + private fun dump(clients: String) = + """ + Tethering: + Device map: + + Client Information: + $clients + IPv4 Upstream Indices: + + """.trimIndent() + + @Test fun emptyClientListIsZero() = assertEquals(0, TetheringDump.clientCount(dump(" "))) + + @Test fun countsEachClientAcrossInterfaces() { + val clients = + """ + | {wlan1=[ClientInfo{client: /192.168.43.2, hwAddr: aa}, ClientInfo{client: /192.168.43.3, hwAddr: bb}]} + | {rndis0=[ClientInfo{client: /192.168.42.2, hwAddr: cc}]} + """.trimMargin() + assertEquals(3, TetheringDump.clientCount(dump(clients))) + } + + @Test fun missingSectionIsUnknown() = assertNull(TetheringDump.clientCount("Tethering:\n Device map:\n ")) +} From 1d8e76aee401121552790d5875dffcadf2272cbd Mon Sep 17 00:00:00 2001 From: sameerasw Date: Tue, 13 Oct 2026 13:19:06 +0530 Subject: [PATCH 4/4] feat: update hotspot plugin UI with client count badge and adjust default permissions --- .../data/repository/SettingsRepository.kt | 2 +- .../domain/registry/PermissionRegistry.kt | 2 + .../island/plugins/hotspot/HotspotPlugin.kt | 68 +++++++++++++++++-- .../ui/features/display/IslandSettingsUI.kt | 8 ++- .../essentials/viewmodels/MainViewModel.kt | 2 +- 5 files changed, 71 insertions(+), 11 deletions(-) diff --git a/app/src/main/java/com/sameerasw/essentials/data/repository/SettingsRepository.kt b/app/src/main/java/com/sameerasw/essentials/data/repository/SettingsRepository.kt index bb73a8aae..ad2262c82 100644 --- a/app/src/main/java/com/sameerasw/essentials/data/repository/SettingsRepository.kt +++ b/app/src/main/java/com/sameerasw/essentials/data/repository/SettingsRepository.kt @@ -4052,7 +4052,7 @@ class SettingsRepository( fun isIslandShowDevicesEnabled(): Boolean = getBoolean(KEY_ISLAND_SHOW_DEVICES, true) fun setIslandShowDevicesEnabled(enabled: Boolean) = putBoolean(KEY_ISLAND_SHOW_DEVICES, enabled) - fun isIslandShowHotspotEnabled(): Boolean = getBoolean(KEY_ISLAND_SHOW_HOTSPOT, true) + fun isIslandShowHotspotEnabled(): Boolean = getBoolean(KEY_ISLAND_SHOW_HOTSPOT, false) fun setIslandShowHotspotEnabled(enabled: Boolean) = putBoolean(KEY_ISLAND_SHOW_HOTSPOT, enabled) fun isIslandBriefEnabled(): Boolean = getBoolean(KEY_ISLAND_BRIEF_ENABLED, false) diff --git a/app/src/main/java/com/sameerasw/essentials/domain/registry/PermissionRegistry.kt b/app/src/main/java/com/sameerasw/essentials/domain/registry/PermissionRegistry.kt index e0e358bc1..7ea416197 100644 --- a/app/src/main/java/com/sameerasw/essentials/domain/registry/PermissionRegistry.kt +++ b/app/src/main/java/com/sameerasw/essentials/domain/registry/PermissionRegistry.kt @@ -98,10 +98,12 @@ fun initPermissionRegistry() { PermissionRegistry.register("SHIZUKU", R.string.feat_standby_apps_title) PermissionRegistry.register("SHIZUKU", R.string.island_dynamic_hide_status_bar_title) PermissionRegistry.register("SHIZUKU", R.string.island_hide_live_updates_title) + PermissionRegistry.register("SHIZUKU", R.string.island_show_hotspot_title) PermissionRegistry.register("USAGE_STATS", R.string.feat_freeze_title) PermissionRegistry.register("USAGE_STATS", R.string.feat_app_lock_title) PermissionRegistry.register("USAGE_STATS", R.string.feat_dynamic_night_light_title) PermissionRegistry.register("USAGE_STATS", R.string.feat_conscious_gate_title) + PermissionRegistry.register("USAGE_STATS", R.string.island_show_hotspot_title) PermissionRegistry.register("NOTIFICATION_LISTENER", R.string.feat_freeze_title) // Root permission diff --git a/app/src/main/java/com/sameerasw/essentials/island/plugins/hotspot/HotspotPlugin.kt b/app/src/main/java/com/sameerasw/essentials/island/plugins/hotspot/HotspotPlugin.kt index c90868233..c173dbbce 100644 --- a/app/src/main/java/com/sameerasw/essentials/island/plugins/hotspot/HotspotPlugin.kt +++ b/app/src/main/java/com/sameerasw/essentials/island/plugins/hotspot/HotspotPlugin.kt @@ -10,8 +10,27 @@ import android.net.ConnectivityManager import android.net.wifi.WifiManager import android.text.format.Formatter import android.util.Log +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.LocalTextStyle +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.graphics.BlendMode +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.CompositingStrategy +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.text.ExperimentalTextApi +import androidx.compose.ui.text.PlatformTextStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.LineHeightStyle +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp import com.sameerasw.essentials.R import com.sameerasw.essentials.data.repository.SettingsRepository import com.sameerasw.essentials.island.model.CompactCell @@ -21,7 +40,6 @@ import com.sameerasw.essentials.island.model.IslandPriority import com.sameerasw.essentials.island.model.LineContent import com.sameerasw.essentials.island.plugins.BaseIslandPlugin import com.sameerasw.essentials.island.ui.components.IslandIcon -import com.sameerasw.essentials.island.ui.components.RollingText import com.sameerasw.essentials.utils.PermissionUtils import com.sameerasw.essentials.utils.ShellUtils import kotlinx.coroutines.Dispatchers @@ -161,11 +179,8 @@ class HotspotPlugin : BaseIslandPlugin() { key = KEY, priority = IslandPriority.HOTSPOT, placement = CompactPlacement.Dynamic, - compact = listOfNotNull( - count?.let { CompactCell("hotspot.clients") { RollingText(it.toString()) } }, - CompactCell("hotspot.icon") { - IslandIcon(R.drawable.rounded_wifi_tethering_24, size = 18.dp, tint = MaterialTheme.colorScheme.primary) - }, + compact = listOf( + CompactCell("hotspot.icon") { HotspotIcon(count) }, ), line = LineContent( icon = { IslandIcon(R.drawable.rounded_wifi_tethering_24, tint = MaterialTheme.colorScheme.primary) }, @@ -187,6 +202,47 @@ class HotspotPlugin : BaseIslandPlugin() { } } +@OptIn(ExperimentalTextApi::class) +@Composable +private fun HotspotIcon(count: Int?) { + Box( + modifier = Modifier + .size(24.dp) + .graphicsLayer { compositingStrategy = CompositingStrategy.Offscreen }, + ) { + IslandIcon( + R.drawable.rounded_wifi_tethering_24, + size = 20.dp, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.align(Alignment.TopStart), + ) + if (count != null) { + Box( + modifier = Modifier + .align(Alignment.BottomEnd) + .size(11.dp) + .drawBehind { drawCircle(Color.Black, blendMode = BlendMode.Clear) }, + ) + Text( + text = if (count > 9) "9+" else count.toString(), + color = MaterialTheme.colorScheme.primary, + fontSize = if (count > 9) 6.sp else 8.sp, + fontWeight = FontWeight.Bold, + maxLines = 1, + softWrap = false, + textAlign = TextAlign.Center, + modifier = Modifier + .align(Alignment.BottomEnd) + .width(11.dp), + style = LocalTextStyle.current.copy( + platformStyle = PlatformTextStyle(includeFontPadding = false), + lineHeightStyle = LineHeightStyle(LineHeightStyle.Alignment.Center, LineHeightStyle.Trim.Both), + ), + ) + } + } +} + object TetheringDump { fun clientCount(dump: String): Int? { val lines = dump.lineSequence().toList() diff --git a/app/src/main/java/com/sameerasw/essentials/ui/features/display/IslandSettingsUI.kt b/app/src/main/java/com/sameerasw/essentials/ui/features/display/IslandSettingsUI.kt index 7fef587a2..ce65882cc 100644 --- a/app/src/main/java/com/sameerasw/essentials/ui/features/display/IslandSettingsUI.kt +++ b/app/src/main/java/com/sameerasw/essentials/ui/features/display/IslandSettingsUI.kt @@ -434,13 +434,15 @@ fun IslandSettingsUI( dragHandle = handle, iconRes = R.drawable.rounded_wifi_tethering_24, title = stringResource(R.string.island_show_hotspot_title), - description = stringResource(R.string.island_hotspot_desc), isChecked = viewModel.isIslandShowHotspot.value, onCheckedChange = { checked -> HapticUtil.performVirtualKeyHaptic(view) viewModel.setIslandShowHotspot(checked) - if (checked && !PermissionUtils.hasUsageStatsPermission(context)) { - requestingPermissionsFor = Pair(R.string.island_title, listOf("USAGE_STATS")) + if (checked) { + val missing = mutableListOf() + if (!PermissionUtils.hasUsageStatsPermission(context)) missing.add("USAGE_STATS") + if (!ShellUtils.isAvailable(context) || !ShellUtils.hasPermission(context)) missing.add("SHIZUKU") + if (missing.isNotEmpty()) requestingPermissionsFor = Pair(R.string.island_title, missing) } }, modifier = Modifier.highlight(highlightSetting == "island_show_hotspot"), diff --git a/app/src/main/java/com/sameerasw/essentials/viewmodels/MainViewModel.kt b/app/src/main/java/com/sameerasw/essentials/viewmodels/MainViewModel.kt index 0c3e19018..e634e6390 100644 --- a/app/src/main/java/com/sameerasw/essentials/viewmodels/MainViewModel.kt +++ b/app/src/main/java/com/sameerasw/essentials/viewmodels/MainViewModel.kt @@ -241,7 +241,7 @@ class MainViewModel : ViewModel() { val islandAlarmWindowHours = mutableIntStateOf(12) val isIslandShowTravel = mutableStateOf(true) val isIslandShowCaffeinate = mutableStateOf(true) - val isIslandShowHotspot = mutableStateOf(true) + val isIslandShowHotspot = mutableStateOf(false) val isIslandShowDevices = mutableStateOf(true) val isIslandBriefEnabled = mutableStateOf(false) val isIslandBriefShowAlarm = mutableStateOf(true)