Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down
Original file line number Diff line number Diff line change
@@ -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() == "<empty>" }
return body.sumOf { Regex("client: /").findAll(it).count() }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -141,6 +142,7 @@ class IslandCoordinator(
TravelPlugin(),
NetworkPlugin(),
SignalPlugin(),
HotspotPlugin(),
DevicesPlugin(),
BriefPlugin(),
BatteryAlertsPlugin(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>()
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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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)
Expand Down
6 changes: 6 additions & 0 deletions app/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -3157,6 +3157,12 @@
<string name="duo_combine_with_island">Combine with Island</string>
<string name="island_combine_with_duo">Combine with Duo</string>
<string name="island_show_devices_title">Devices</string>
<string name="island_show_hotspot_title">Hotspot</string>
<string name="island_hotspot_desc">Shows while your hotspot is on, with connected devices and data shared</string>
<plurals name="island_hotspot_clients">
<item quantity="one">%d device</item>
<item quantity="other">%d devices</item>
</plurals>
<string name="island_devices_connected">Connected</string>
<string name="island_devices_disconnected">Disconnected</string>
<string name="island_devices_unknown">Bluetooth device</string>
Expand Down
Loading
Loading