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..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 @@ -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, false) + 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/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/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..c173dbbce --- /dev/null +++ b/app/src/main/java/com/sameerasw/essentials/island/plugins/hotspot/HotspotPlugin.kt @@ -0,0 +1,254 @@ +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 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 +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.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() { + syncPolling() + render() + } + + private fun setEnabled(on: Boolean) { + if (ctx == null || on == enabled) return + enabled = on + 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() } + 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 (e: Exception) { + Log.d(TAG, "Tethered bytes query failed", e) + null + } + } + + private fun render() { + if (ctx == null || !enabled || !settings.isIslandShowHotspotEnabled()) { + publish(null) + return + } + 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) }, + ).joinToString(" ยท ") + publish( + IslandItem( + key = KEY, + priority = IslandPriority.HOTSPOT, + placement = CompactPlacement.Dynamic, + compact = listOf( + CompactCell("hotspot.icon") { HotspotIcon(count) }, + ), + 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 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 + private const val POLL_MS = 10_000L + private const val WINDOW_MS = 24 * 60 * 60 * 1000L + } +} + +@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() + 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..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 @@ -430,6 +430,23 @@ 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), + isChecked = viewModel.isIslandShowHotspot.value, + onCheckedChange = { checked -> + HapticUtil.performVirtualKeyHaptic(view) + viewModel.setIslandShowHotspot(checked) + 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"), + ) "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..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,6 +241,7 @@ class MainViewModel : ViewModel() { val islandAlarmWindowHours = mutableIntStateOf(12) val isIslandShowTravel = mutableStateOf(true) val isIslandShowCaffeinate = mutableStateOf(true) + val isIslandShowHotspot = mutableStateOf(false) 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 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 ")) +}