Skip to content
Open
Show file tree
Hide file tree
Changes from 9 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
15 changes: 15 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,21 @@
</intent-filter>
</receiver>

<receiver
android:name=".service.AdBlockDeviceAdminReceiver"
android:description="@string/device_admin_description"
android:exported="true"
android:permission="android.permission.BIND_DEVICE_ADMIN">
<meta-data
android:name="android.app.device_admin"
android:resource="@xml/device_admin_policies" />
<intent-filter>
<action android:name="android.app.action.DEVICE_ADMIN_ENABLED" />
<action android:name="android.app.action.DEVICE_ADMIN_DISABLED" />
<action android:name="android.app.action.PROFILE_PROVISIONING_COMPLETE" />
</intent-filter>
</receiver>

<service
android:name=".service.AdBlockTileService"
android:exported="true"
Expand Down
6 changes: 6 additions & 0 deletions app/src/main/java/app/pwhs/blockads/BlockAdsApplication.kt
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,12 @@ class BlockAdsApplication : Application() {
if (appPreferences.dailySummaryEnabled.first()) {
DailySummaryScheduler.scheduleDailySummary(this@BlockAdsApplication)
}

// Device Owner Mode: enforce restrictions if provisioned AND enabled in settings
val deviceOwnerManager = app.pwhs.blockads.service.DeviceOwnerManager(this@BlockAdsApplication)
if (deviceOwnerManager.isDeviceOwner() && appPreferences.deviceOwnerRestrictionsEnabled.first()) {
deviceOwnerManager.enforceRestrictions()
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
}
}

// Trusted Wi-Fi networks (#197): auto-pause/resume on SSID change.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,13 @@ class AppPreferences(private val context: Context) {
private val KEY_PAUSE_ON_TRUSTED = booleanPreferencesKey("pause_on_trusted")
private val KEY_PAUSED_BY_TRUSTED = booleanPreferencesKey("paused_by_trusted")
private val KEY_PAUSED_TRUSTED_SSID = stringPreferencesKey("paused_trusted_ssid")
private val KEY_LOCKDOWN_ENABLED = booleanPreferencesKey("lockdown_enabled")
private val KEY_LOCKDOWN_DURATION = longPreferencesKey("lockdown_duration")
private val KEY_COOLDOWN_START_TIMESTAMP = longPreferencesKey("cooldown_start_timestamp")
private val KEY_LAST_ACTIVE_TIMESTAMP = longPreferencesKey("last_active_timestamp")
private val KEY_LAST_ACTIVE_REALTIME = longPreferencesKey("last_active_realtime")
private val KEY_DEVICE_OWNER_RESTRICTIONS_ENABLED =
booleanPreferencesKey("device_owner_restrictions_enabled")

const val ROUTING_MODE_DIRECT = "direct"
const val ROUTING_MODE_WIREGUARD = "wireguard"
Expand Down Expand Up @@ -144,12 +151,48 @@ class AppPreferences(private val context: Context) {
const val DEFAULT_FALLBACK_DNS = "94.140.14.14"
const val DEFAULT_DNS_PROTOCOL = "PLAIN"
const val DEFAULT_DOH_URL = "https://dns.quad9.net/dns-query"
const val DEFAULT_LOCKDOWN_DURATION = 300000L
val ALLOWED_LOCKDOWN_DURATIONS = setOf(
60000L,
300000L,
600000L,
1800000L,
3600000L,
21600000L,
43200000L,
86400000L
)
}

val vpnEnabled: Flow<Boolean> = context.dataStore.data.map { prefs ->
prefs[KEY_VPN_ENABLED] ?: false
}

val lockdownEnabled: Flow<Boolean> = context.dataStore.data.map { prefs ->
prefs[KEY_LOCKDOWN_ENABLED] ?: false
}

val lockdownDuration: Flow<Long> = context.dataStore.data.map { prefs ->
val duration = prefs[KEY_LOCKDOWN_DURATION] ?: DEFAULT_LOCKDOWN_DURATION
if (duration in ALLOWED_LOCKDOWN_DURATIONS) duration else DEFAULT_LOCKDOWN_DURATION
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

val cooldownStartTimestamp: Flow<Long> = context.dataStore.data.map { prefs ->
prefs[KEY_COOLDOWN_START_TIMESTAMP] ?: 0L
}

val lastActiveTimestamp: Flow<Long> = context.dataStore.data.map { prefs ->
prefs[KEY_LAST_ACTIVE_TIMESTAMP] ?: 0L
}

val lastActiveRealtime: Flow<Long> = context.dataStore.data.map { prefs ->
prefs[KEY_LAST_ACTIVE_REALTIME] ?: 0L
}

val deviceOwnerRestrictionsEnabled: Flow<Boolean> = context.dataStore.data.map { prefs ->
prefs[KEY_DEVICE_OWNER_RESTRICTIONS_ENABLED] ?: true
}

val autoReconnect: Flow<Boolean> = context.dataStore.data.map { prefs ->
prefs[KEY_AUTO_RECONNECT] ?: true
}
Expand Down Expand Up @@ -383,6 +426,50 @@ class AppPreferences(private val context: Context) {
}
}

suspend fun setLockdownEnabled(enabled: Boolean) {
context.dataStore.edit { prefs ->
prefs[KEY_LOCKDOWN_ENABLED] = enabled
}
}

suspend fun setDeviceOwnerRestrictionsEnabled(enabled: Boolean) {
context.dataStore.edit { prefs ->
prefs[KEY_DEVICE_OWNER_RESTRICTIONS_ENABLED] = enabled
}
}

suspend fun setLockdownDuration(ms: Long) {
val validMs = if (ms in ALLOWED_LOCKDOWN_DURATIONS) ms else DEFAULT_LOCKDOWN_DURATION
context.dataStore.edit { prefs ->
prefs[KEY_LOCKDOWN_DURATION] = validMs
}
}

suspend fun setCooldownStartTimestamp(timestamp: Long) {
context.dataStore.edit { prefs ->
prefs[KEY_COOLDOWN_START_TIMESTAMP] = timestamp
}
}

suspend fun setLastActiveTimestamp(timestamp: Long) {
context.dataStore.edit { prefs ->
prefs[KEY_LAST_ACTIVE_TIMESTAMP] = timestamp
}
}

suspend fun setLastActiveRealtime(timestamp: Long) {
context.dataStore.edit { prefs ->
prefs[KEY_LAST_ACTIVE_REALTIME] = timestamp
}
}

suspend fun setLastActiveBaselines(wallTimestamp: Long, realtimeTimestamp: Long) {
context.dataStore.edit { prefs ->
prefs[KEY_LAST_ACTIVE_TIMESTAMP] = wallTimestamp
prefs[KEY_LAST_ACTIVE_REALTIME] = realtimeTimestamp
}
}

suspend fun setAutoReconnect(enabled: Boolean) {
context.dataStore.edit { prefs ->
prefs[KEY_AUTO_RECONNECT] = enabled
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package app.pwhs.blockads.service

import android.app.admin.DeviceAdminReceiver
import android.content.Context
import android.content.Intent

class AdBlockDeviceAdminReceiver : DeviceAdminReceiver() {
override fun onEnabled(context: Context, intent: Intent) {
super.onEnabled(context, intent)
}

override fun onDisabled(context: Context, intent: Intent) {
super.onDisabled(context, intent)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,10 @@ import app.pwhs.blockads.MainActivity
import app.pwhs.blockads.R
import app.pwhs.blockads.utils.VpnUtils

import org.koin.android.ext.android.inject
import org.koin.android.ext.android.inject
import app.pwhs.blockads.data.datastore.AppPreferences
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking

class AdBlockTileService : TileService() {
Expand All @@ -33,6 +35,12 @@ class AdBlockTileService : TileService() {
val isRootProxyRunning = RootProxyService.isRunning
val isVpnRunning = AdBlockVpnService.isRunning

val isLocked = runBlocking { appPrefs.lockdownEnabled.first() }
if (isLocked && (isRootProxyRunning || isVpnRunning)) {
updateTileState()
return
}

Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
if (isRootProxyRunning) {
RootProxyService.stop(this)
} else if (isVpnRunning) {
Expand Down
10 changes: 10 additions & 0 deletions app/src/main/java/app/pwhs/blockads/service/AdBlockVpnService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -269,11 +269,21 @@ class AdBlockVpnService : VpnService() {

when (intent?.action) {
ACTION_STOP -> {
val isLocked = runBlocking { appPrefs.lockdownEnabled.first() }
if (isLocked) {
Timber.w("Stop request ignored: VPN is in Lockdown Mode.")
return START_STICKY
}
stopVpn()
return START_NOT_STICKY
}

ACTION_PAUSE_1H -> {
val isLocked = runBlocking { appPrefs.lockdownEnabled.first() }
if (isLocked) {
Timber.w("Pause request ignored: VPN is in Lockdown Mode.")
return START_STICKY
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
pauseVpn()
return START_NOT_STICKY
}
Expand Down
3 changes: 2 additions & 1 deletion app/src/main/java/app/pwhs/blockads/service/BootReceiver.kt
Original file line number Diff line number Diff line change
Expand Up @@ -26,11 +26,12 @@ class BootReceiver : BroadcastReceiver() {
val autoReconnect = prefs.autoReconnect.first()
val wasEnabled = prefs.vpnEnabled.first()
val routingMode = prefs.routingMode.first()
val isLocked = prefs.lockdownEnabled.first()

// Root Mode: iptables rules are volatile (cleared on reboot).
// Re-apply rules by starting RootProxyService.
// Also restarts after app update (MY_PACKAGE_REPLACED).
if (autoReconnect && wasEnabled) {
if (isLocked || (autoReconnect && wasEnabled)) {
val trigger = if (intent.action == Intent.ACTION_MY_PACKAGE_REPLACED) "app update" else "boot"
if (routingMode == AppPreferences.ROUTING_MODE_ROOT) {
Timber.d("Auto-starting Root Proxy mode after $trigger")
Expand Down
107 changes: 107 additions & 0 deletions app/src/main/java/app/pwhs/blockads/service/DeviceOwnerManager.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
package app.pwhs.blockads.service

import android.app.admin.DevicePolicyManager
import android.content.ComponentName
import android.content.Context
import android.os.UserManager
import timber.log.Timber

class DeviceOwnerManager(private val context: Context) {

private val devicePolicyManager =
context.getSystemService(Context.DEVICE_POLICY_SERVICE) as DevicePolicyManager
private val componentName = ComponentName(context, AdBlockDeviceAdminReceiver::class.java)

companion object {
val REQUIRED_RESTRICTIONS = listOf(
UserManager.DISALLOW_CONFIG_VPN,
UserManager.DISALLOW_DEBUGGING_FEATURES
)
}

fun isDeviceOwner(): Boolean {
return devicePolicyManager.isDeviceOwnerApp(context.packageName)
}

fun areRestrictionsEnforced(): Boolean {
if (!isDeviceOwner()) return false

val alwaysOnVpnPackage = try {
devicePolicyManager.getAlwaysOnVpnPackage(componentName)
} catch (e: Exception) {
Timber.e(e, "Failed to get always-on VPN package")
null
}
val isAlwaysOnVpnSet = alwaysOnVpnPackage == context.packageName

val userManager = context.getSystemService(Context.USER_SERVICE) as? UserManager
?: return false

val allRestrictionsSet = REQUIRED_RESTRICTIONS.all { restriction ->
userManager.hasUserRestriction(restriction)
}

return isAlwaysOnVpnSet && allRestrictionsSet
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

fun enforceRestrictions(): Boolean {
if (!isDeviceOwner()) return false

Timber.d("Enforcing Device Owner restrictions")
var success = true

// Set as always-on VPN FIRST before applying DISALLOW_CONFIG_VPN
// If we apply the restriction first, the OS immediately kills the active VPN
try {
devicePolicyManager.setAlwaysOnVpnPackage(
componentName,
context.packageName,
false // lockdown disabled at OS level to allow bypassed apps and the VPN itself to access the internet
)
} catch (e: Exception) {
Timber.e(e, "Failed to set always-on VPN via DPM")
success = false
}

for (restriction in REQUIRED_RESTRICTIONS) {
try {
devicePolicyManager.addUserRestriction(componentName, restriction)
} catch (e: Exception) {
Timber.e(e, "Failed to add restriction $restriction via DPM")
success = false
}
}

return success
}

fun clearRestrictions(): Boolean {
if (!isDeviceOwner()) return false

Timber.d("Clearing Device Owner restrictions")
var success = true

try {
devicePolicyManager.setAlwaysOnVpnPackage(
componentName,
null,
false
)
} catch (e: Exception) {
Timber.e(e, "Failed to clear always-on VPN via DPM")
success = false
}

for (restriction in REQUIRED_RESTRICTIONS) {
try {
devicePolicyManager.clearUserRestriction(componentName, restriction)
} catch (e: Exception) {
Timber.e(e, "Failed to clear restriction $restriction via DPM")
success = false
}
}

return success
}

}
10 changes: 10 additions & 0 deletions app/src/main/java/app/pwhs/blockads/service/RootProxyService.kt
Original file line number Diff line number Diff line change
Expand Up @@ -148,10 +148,20 @@ class RootProxyService : Service() {

when (intent?.action) {
ACTION_STOP -> {
val isLocked = kotlinx.coroutines.runBlocking { appPrefs.lockdownEnabled.first() }
if (isLocked) {
Timber.w("Stop request ignored: Root Proxy is in Lockdown Mode.")
return START_STICKY
}
stopProxy()
return START_NOT_STICKY
}
ACTION_PAUSE_1H -> {
val isLocked = kotlinx.coroutines.runBlocking { appPrefs.lockdownEnabled.first() }
if (isLocked) {
Timber.w("Pause request ignored: Root Proxy is in Lockdown Mode.")
return START_STICKY
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
pauseProxy()
return START_NOT_STICKY
}
Expand Down
19 changes: 14 additions & 5 deletions app/src/main/java/app/pwhs/blockads/service/ServiceController.kt
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import timber.log.Timber

/**
* Unified service controller that dispatches restart/stop requests
Expand Down Expand Up @@ -52,11 +53,19 @@ object ServiceController {
* Stop whichever service is currently running.
*/
fun requestStop(context: Context) {
if (RootProxyService.isRunning) {
RootProxyService.stop(context)
}
if (AdBlockVpnService.isRunning) {
AdBlockVpnService.stop(context)
CoroutineScope(Dispatchers.IO).launch {
val appPrefs = AppPreferences(context)
val isLocked = appPrefs.lockdownEnabled.first()
if (isLocked) {
Timber.w("Stop request ignored: System is in Lockdown Mode.")
return@launch
}
if (RootProxyService.isRunning) {
RootProxyService.stop(context)
}
if (AdBlockVpnService.isRunning) {
AdBlockVpnService.stop(context)
}
}
}
}
Loading