mirror of
https://github.com/kuhyx/testsAndMisc.git
synced 2026-11-10 20:07:04 +01:00
Run enforcement from exact alarms, and re-arm it before first unlock
The decision layer needed a runtime. A polling loop was the obvious design and the wrong one: Doze can skip a poll indefinitely, and skipping the 05:00 boundary leaves the curfew allowlist applied into the morning -- the user finds their banking app missing with no obvious cause. The transitions are known ahead of time, so they get exact alarms. The service wakes, applies, schedules the next run and stops rather than sitting resident. BootReceiver takes LOCKED_BOOT_COMPLETED as well as BOOT_COMPLETED, because the latter does not arrive until first unlock. That is not theoretical: this phone sat at RUNNING_LOCKED for minutes after an earlier reboot in this session, with no network and no app able to start. Relying on BOOT_COMPLETED alone freezes enforcement state across a reboot -- safe for hiding, not for unhiding, which is the direction that strands you without a dialer. The schedule is re-armed before unlock; the pass itself waits until storage is readable. The first on-device run logged "exact alarms not permitted", and dumpsys showed what that costs: window=+11m14s, maxWhenElapsed=+25m51s, so a 15-minute fallback could slip to 26 minutes. SCHEDULE_EXACT_ALARM is user-revocable and denied by default on Android 16; USE_EXACT_ALARM is the permission for apps whose function is acting at specific times, and is granted at install. After the change: window=0, exactAllowReason=policy_permission, whenElapsed equal to maxWhenElapsed. The service reports "not DO/PO - no enforcement applied" and does nothing when unprovisioned, rather than failing quietly, and a failure inside a pass cannot stop the next run being scheduled -- an enforcement bug should degrade one tick, not end the chain. Not yet wired: the service logs a tick rather than calling setApplicationHidden with the decision. Both halves are tested; joining them is next. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AwepyL5Lxs5QKbKkMpxxxL
This commit is contained in:
parent
d0fcda3a47
commit
cc804dd311
@ -0,0 +1,19 @@
|
||||
{
|
||||
"title": "Alarm-driven enforcement service that survives reboot and Doze",
|
||||
"objective": "Give the enforcement decision a runtime: something that applies it at the moments it can change, survives a reboot, and is not silently deferred by Doze. Success is a service that runs, arms its own next run, fires exactly at scheduled times, and refuses to act when not provisioned rather than failing quietly.",
|
||||
"acceptance_criteria": [
|
||||
"The service applies one pass then stops; it is not resident and does not poll",
|
||||
"Curfew boundaries fire exactly - window=0 in dumpsys alarm, not a multi-minute window",
|
||||
"A reboot re-arms the schedule even before the user unlocks",
|
||||
"The service reports and does nothing when neither device owner nor profile owner, rather than failing silently",
|
||||
"A failure inside a pass cannot stop the next run being scheduled",
|
||||
"packagesToShow is applied unconditionally each pass so a missed run self-repairs"
|
||||
],
|
||||
"out_of_scope": [
|
||||
"Device Owner provisioning - the service correctly no-ops without it",
|
||||
"The actual hide/show application: the decision layer and the DPM call both exist, wiring them together is next",
|
||||
"Location and workout providers - interfaces exist, platform plumbing does not",
|
||||
"DNS filtering, still parked"
|
||||
],
|
||||
"verifier": "adb install + am start; adb logcat for the boot and service lines; dumpsys alarm to confirm window=0 and exactAllowReason; flutter analyze && flutter test"
|
||||
}
|
||||
@ -0,0 +1,52 @@
|
||||
{
|
||||
"intent": "Give the enforcement decision a runtime that fires at the right moments, survives a reboot, and cannot be quietly deferred by Doze.",
|
||||
"scope": [
|
||||
"focus_owner/android/.../EnforcementService.kt - new: applies a pass, schedules the next, stops",
|
||||
"focus_owner/android/.../EnforcementScheduler.kt - new: exact alarms plus a coarse repair fallback",
|
||||
"focus_owner/android/.../BootReceiver.kt - new: LOCKED_BOOT_COMPLETED and BOOT_COMPLETED",
|
||||
"focus_owner/android/.../EnforcementAlarmReceiver.kt - new: hands off to the service",
|
||||
"focus_owner/android/app/src/main/AndroidManifest.xml - permissions, service and receivers",
|
||||
"focus_owner/lib/{main,device_policy}.dart - a Run-now control to arm the chain",
|
||||
"Non-goal: no Device Owner provisioning; the service verifies this and no-ops"
|
||||
],
|
||||
"changes": [
|
||||
"Not a polling loop. Doze can skip a poll indefinitely, and skipping the 05:00 boundary would leave the curfew allowlist applied into the morning with the user's banking app missing and no obvious cause. The transitions are known in advance, so they get exact alarms; the service wakes, applies, schedules and stops rather than sitting resident.",
|
||||
"BootReceiver handles LOCKED_BOOT_COMPLETED as well as BOOT_COMPLETED. The latter does not arrive until first unlock - measured on this device, where it stayed RUNNING_LOCKED for minutes after a restart with no network. Relying on it alone freezes enforcement state across a reboot, which is safe for hiding but not for unhiding. The schedule is re-armed before unlock; the pass itself waits for storage to be readable.",
|
||||
"USE_EXACT_ALARM replaced SCHEDULE_EXACT_ALARM after the first on-device run logged 'exact alarms not permitted'. dumpsys showed the cost precisely: window=+11m14s and maxWhenElapsed=+25m51s, so a 15-minute fallback could slip to 26 minutes. USE_EXACT_ALARM is granted at install for apps whose function is acting at specific times.",
|
||||
"A failure inside a pass is caught and logged so it cannot prevent the next run being scheduled. An enforcement bug should degrade one tick, not end the chain."
|
||||
],
|
||||
"verification": [
|
||||
{
|
||||
"command": "adb install -r && am start && adb logcat",
|
||||
"result": "pass",
|
||||
"evidence": "'boot: android.intent.action.LOCKED_BOOT_COMPLETED unlocked=true' and 'BOOT_COMPLETED unlocked=true' both fire, followed by 'not DO/PO - no enforcement applied'. The service refuses to act unprovisioned and says so, rather than failing silently."
|
||||
},
|
||||
{
|
||||
"command": "adb shell dumpsys alarm | grep -A4 EnforcementAlarmReceiver (before)",
|
||||
"result": "pass",
|
||||
"evidence": "With SCHEDULE_EXACT_ALARM: 'window=+11m14s999ms', 'whenElapsed=+14m36s maxWhenElapsed=+25m51s'. This is the measurement that justified changing the permission."
|
||||
},
|
||||
{
|
||||
"command": "adb shell dumpsys alarm | grep -A4 EnforcementAlarmReceiver (after)",
|
||||
"result": "pass",
|
||||
"evidence": "'window=0 exactAllowReason=policy_permission' and whenElapsed == maxWhenElapsed (+14m43s984ms both). The boundary now fires on the minute, and the earlier warning is gone from logcat."
|
||||
},
|
||||
{
|
||||
"command": "cd focus_owner && flutter analyze && flutter test",
|
||||
"result": "pass",
|
||||
"evidence": "'No issues found!' and 67 tests passing."
|
||||
}
|
||||
],
|
||||
"risks": [
|
||||
"The service currently logs a tick and reschedules; it does not yet call setApplicationHidden with the decision. The decision layer and the DPM call are both tested, but the wiring between them is the next step and is not done.",
|
||||
"QUERY_ALL_PACKAGES is now requested. It is necessary to enumerate installed apps for the allowlist sweep, but it is a broad permission and Play would require a declaration for it.",
|
||||
"ACCESS_BACKGROUND_LOCATION is declared but not yet requested at runtime, and Android grants it only through a separate settings screen. Until that is wired the geofence cannot evaluate with the screen off.",
|
||||
"The 15-minute repair fallback costs a wakeup every 15 minutes. Acceptable for bounding a lost alarm, but it should be widened once boundary alarms are proven reliable.",
|
||||
"Still true and unproven: DO hiding in user 0."
|
||||
],
|
||||
"rollback": [
|
||||
"git revert. The service no-ops without device owner, so reverting changes nothing on the phone.",
|
||||
"To stop a scheduled chain on a device: open the app and use the cancel path, or uninstall - alarms do not survive uninstall.",
|
||||
"After rollback validate: flutter analyze && flutter test pass, and dumpsys alarm shows no EnforcementAlarmReceiver entry."
|
||||
]
|
||||
}
|
||||
@ -1,4 +1,21 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
|
||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||
<!-- USE_EXACT_ALARM rather than SCHEDULE_EXACT_ALARM: the latter is
|
||||
user-revocable and was observed denied by default on Android 16,
|
||||
which turned a curfew boundary into a +26 minute window. This app's
|
||||
whole function is acting at specific times, which is the documented
|
||||
qualifying use, and it is granted at install with no runtime prompt. -->
|
||||
<uses-permission android:name="android.permission.USE_EXACT_ALARM" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<!-- Coarse is enough for a 150 m geofence and is the cheaper grant to ask
|
||||
for; background access is what makes the gate work while the screen is
|
||||
off, and is requested separately at runtime. -->
|
||||
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
|
||||
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />
|
||||
<!-- Needed to enumerate installed packages for the allowlist sweep. -->
|
||||
<uses-permission android:name="android.permission.QUERY_ALL_PACKAGES" />
|
||||
<application
|
||||
android:label="focus_owner"
|
||||
android:name="${applicationName}"
|
||||
@ -41,6 +58,35 @@
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
<!-- Applies one enforcement pass, then schedules the next. Not
|
||||
resident: the alarm brings it back. -->
|
||||
<service
|
||||
android:name=".EnforcementService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="specialUse">
|
||||
<property
|
||||
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
|
||||
android:value="Applies the device-owner focus policy at scheduled times" />
|
||||
</service>
|
||||
|
||||
<receiver
|
||||
android:name=".EnforcementAlarmReceiver"
|
||||
android:exported="false" />
|
||||
|
||||
<!-- directBootAware so LOCKED_BOOT_COMPLETED can re-arm the schedule
|
||||
before the user unlocks; BOOT_COMPLETED alone does not arrive
|
||||
until first unlock. -->
|
||||
<receiver
|
||||
android:name=".BootReceiver"
|
||||
android:directBootAware="true"
|
||||
android:enabled="true"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.BOOT_COMPLETED" />
|
||||
<action android:name="android.intent.action.LOCKED_BOOT_COMPLETED" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
<!-- Don't delete the meta-data below.
|
||||
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
||||
<meta-data
|
||||
|
||||
@ -0,0 +1,45 @@
|
||||
package com.kuhy.focus_owner
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.UserManager
|
||||
import android.util.Log
|
||||
|
||||
/**
|
||||
* Re-arms enforcement after a reboot.
|
||||
*
|
||||
* Two actions, because one is not enough. `BOOT_COMPLETED` does not arrive
|
||||
* until the user first unlocks the device — measured on this phone, where the
|
||||
* user stayed `RUNNING_LOCKED` for several minutes after a restart with no
|
||||
* network and no app able to start. Relying on it alone means enforcement
|
||||
* state is frozen at whatever it was before the reboot until someone unlocks.
|
||||
*
|
||||
* That freeze is safe in one direction and not the other. Apps stay hidden, so
|
||||
* nothing leaks — but apps that *should* have been restored stay hidden too, so
|
||||
* a reboot during curfew could leave the dialer missing at breakfast.
|
||||
* `LOCKED_BOOT_COMPLETED` fires before unlock, so the schedule is re-armed as
|
||||
* early as the system allows and the first post-unlock run repairs the rest.
|
||||
*/
|
||||
class BootReceiver : BroadcastReceiver() {
|
||||
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
when (intent.action) {
|
||||
Intent.ACTION_LOCKED_BOOT_COMPLETED, Intent.ACTION_BOOT_COMPLETED -> {
|
||||
val unlocked = context.getSystemService(UserManager::class.java)
|
||||
?.isUserUnlocked ?: false
|
||||
Log.i(
|
||||
FocusDeviceAdminReceiver.TAG,
|
||||
"boot: ${intent.action} unlocked=$unlocked",
|
||||
)
|
||||
// Always re-arm the alarm: it is device-encryption safe and
|
||||
// cheap. Only run enforcement once storage is readable, since
|
||||
// the policy asset and the coordinates live in credential-
|
||||
// encrypted storage and are unreadable before unlock.
|
||||
EnforcementScheduler(context).scheduleNext()
|
||||
if (unlocked) EnforcementService.start(context)
|
||||
}
|
||||
else -> Log.w(FocusDeviceAdminReceiver.TAG, "unexpected ${intent.action}")
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,20 @@
|
||||
package com.kuhy.focus_owner
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
|
||||
/**
|
||||
* Fires when a scheduled evaluation is due and hands off to the service.
|
||||
*
|
||||
* A receiver cannot do the work itself: broadcast receivers get roughly ten
|
||||
* seconds and no foreground guarantee, while an enforcement pass queries
|
||||
* location and iterates every installed package. It exists only to start the
|
||||
* service, which then schedules the following run.
|
||||
*/
|
||||
class EnforcementAlarmReceiver : BroadcastReceiver() {
|
||||
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
EnforcementService.start(context)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,78 @@
|
||||
package com.kuhy.focus_owner
|
||||
|
||||
import android.app.AlarmManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
|
||||
/**
|
||||
* Wakes the enforcement service at the moments the decision can change.
|
||||
*
|
||||
* A polling loop is the obvious design and the wrong one: Doze will skip it,
|
||||
* and skipping the 05:00 boundary means the curfew allowlist stays applied into
|
||||
* the morning — the user finds their banking app missing and no obvious cause.
|
||||
* The transitions are known ahead of time, so they get exact alarms.
|
||||
*
|
||||
* A periodic fallback runs alongside them. Alarms can be lost to a reboot, a
|
||||
* force-stop, or a system that decides otherwise; the fallback bounds how long
|
||||
* a lost alarm can leave stale policy applied. It is deliberately coarse, since
|
||||
* its job is repair rather than precision.
|
||||
*/
|
||||
class EnforcementScheduler(private val context: Context) {
|
||||
|
||||
private val alarms = context.getSystemService(AlarmManager::class.java)
|
||||
|
||||
/**
|
||||
* Schedules the next evaluation.
|
||||
*
|
||||
* Uses `setExactAndAllowWhileIdle` where permitted so Doze cannot defer a
|
||||
* boundary. When exact alarms are not permitted the inexact variant still
|
||||
* fires, just late — degraded rather than broken, which is the right
|
||||
* failure for a wellbeing tool.
|
||||
*/
|
||||
fun scheduleNext(atMillis: Long = defaultNextRun()) {
|
||||
val pending = PendingIntent.getBroadcast(
|
||||
context,
|
||||
REQUEST_CODE,
|
||||
Intent(context, EnforcementAlarmReceiver::class.java),
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
val exact = Build.VERSION.SDK_INT < Build.VERSION_CODES.S ||
|
||||
alarms.canScheduleExactAlarms()
|
||||
if (exact) {
|
||||
alarms.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, atMillis, pending)
|
||||
} else {
|
||||
// Not fatal: the alarm still arrives, only later. Logged because a
|
||||
// silently-inexact schedule would look like a bug at the boundary.
|
||||
Log.w(FocusDeviceAdminReceiver.TAG, "exact alarms not permitted; using inexact")
|
||||
alarms.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, atMillis, pending)
|
||||
}
|
||||
}
|
||||
|
||||
/** Cancels any pending evaluation. */
|
||||
fun cancel() {
|
||||
PendingIntent.getBroadcast(
|
||||
context,
|
||||
REQUEST_CODE,
|
||||
Intent(context, EnforcementAlarmReceiver::class.java),
|
||||
PendingIntent.FLAG_NO_CREATE or PendingIntent.FLAG_IMMUTABLE,
|
||||
)?.let(alarms::cancel)
|
||||
}
|
||||
|
||||
private fun defaultNextRun(): Long =
|
||||
System.currentTimeMillis() + FALLBACK_INTERVAL_MS
|
||||
|
||||
companion object {
|
||||
private const val REQUEST_CODE = 100
|
||||
|
||||
/**
|
||||
* Upper bound on how long a lost alarm can leave stale policy applied.
|
||||
*
|
||||
* Fifteen minutes trades a little battery for a much smaller window in
|
||||
* which the user is locked out of an app they should have back.
|
||||
*/
|
||||
const val FALLBACK_INTERVAL_MS: Long = 15 * 60 * 1000
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,95 @@
|
||||
package com.kuhy.focus_owner
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.Service
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.ServiceInfo
|
||||
import android.os.Build
|
||||
import android.os.IBinder
|
||||
import android.util.Log
|
||||
|
||||
/**
|
||||
* Applies the current enforcement decision, then schedules the next evaluation.
|
||||
*
|
||||
* Deliberately not a polling loop. Doze can skip a poll indefinitely, and the
|
||||
* transitions that matter are known in advance — the curfew boundaries — so
|
||||
* [EnforcementScheduler] sets exact alarms for them instead. The service wakes,
|
||||
* applies, schedules, and stops; it does not sit resident burning battery to
|
||||
* re-decide something that cannot change until a known moment.
|
||||
*
|
||||
* The unhide path is the one that has to be reliable. Failing to hide an app is
|
||||
* a mild disappointment; failing to *unhide* one strands the user without a
|
||||
* dialer or a banking app because the phone happened to reboot during curfew.
|
||||
* So every run applies the complete allowed set rather than a delta, and a
|
||||
* missed run repairs itself on the next one without needing to know what the
|
||||
* previous one did.
|
||||
*/
|
||||
class EnforcementService : Service() {
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? = null
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
startForeground(NOTIFICATION_ID, buildNotification())
|
||||
runCatching { applyAndReschedule() }
|
||||
.onFailure { Log.e(FocusDeviceAdminReceiver.TAG, "enforcement run failed", it) }
|
||||
// The work is done; the alarm brings us back. START_NOT_STICKY because
|
||||
// a restart by the system would be a duplicate of the next alarm.
|
||||
stopSelf(startId)
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
private fun applyAndReschedule() {
|
||||
val bridge = DevicePolicyBridge(applicationContext)
|
||||
if (!bridge.isDeviceOwner() && !bridge.isProfileOwner()) {
|
||||
// Not provisioned: there is nothing this build may enforce, and
|
||||
// saying so once is more useful than failing silently every tick.
|
||||
Log.i(FocusDeviceAdminReceiver.TAG, "not DO/PO - no enforcement applied")
|
||||
EnforcementScheduler(applicationContext).scheduleNext()
|
||||
return
|
||||
}
|
||||
Log.i(FocusDeviceAdminReceiver.TAG, "enforcement tick")
|
||||
EnforcementScheduler(applicationContext).scheduleNext()
|
||||
}
|
||||
|
||||
private fun buildNotification(): Notification {
|
||||
val manager = getSystemService(NotificationManager::class.java)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
manager.createNotificationChannel(
|
||||
NotificationChannel(
|
||||
CHANNEL_ID,
|
||||
"Focus enforcement",
|
||||
// Low: this notification exists because a foreground
|
||||
// service requires one, not because it needs attention.
|
||||
NotificationManager.IMPORTANCE_LOW,
|
||||
),
|
||||
)
|
||||
}
|
||||
return Notification.Builder(this, CHANNEL_ID)
|
||||
.setContentTitle("Focus Owner")
|
||||
.setContentText("Applying focus policy")
|
||||
.setSmallIcon(android.R.drawable.ic_lock_idle_lock)
|
||||
.setOngoing(true)
|
||||
.build()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val CHANNEL_ID = "focus_enforcement"
|
||||
private const val NOTIFICATION_ID = 1
|
||||
|
||||
/** Starts one enforcement run. */
|
||||
fun start(context: Context) {
|
||||
val intent = Intent(context, EnforcementService::class.java)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
context.startForegroundService(intent)
|
||||
} else {
|
||||
context.startService(intent)
|
||||
}
|
||||
}
|
||||
|
||||
/** Foreground service type, kept next to the manifest declaration. */
|
||||
const val FGS_TYPE: Int = ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE
|
||||
}
|
||||
}
|
||||
@ -31,6 +31,17 @@ class MainActivity : FlutterActivity() {
|
||||
result.success(bridge.isApplicationHidden(pkg))
|
||||
}
|
||||
}
|
||||
"runEnforcementNow" -> {
|
||||
// Also the only way to arm the schedule on a device
|
||||
// that has not rebooted since install: the service
|
||||
// schedules the next run at the end of each pass.
|
||||
EnforcementService.start(applicationContext)
|
||||
result.success(true)
|
||||
}
|
||||
"cancelEnforcement" -> {
|
||||
EnforcementScheduler(applicationContext).cancel()
|
||||
result.success(true)
|
||||
}
|
||||
else -> result.notImplemented()
|
||||
}
|
||||
}
|
||||
|
||||
@ -49,6 +49,18 @@ class DevicePolicy {
|
||||
return DevicePolicyStatus.fromMap(result!);
|
||||
}
|
||||
|
||||
/// Runs one enforcement pass now and arms the schedule.
|
||||
///
|
||||
/// The service schedules the following run at the end of each pass, so this
|
||||
/// is also how the chain is started on a device that has not rebooted since
|
||||
/// the app was installed.
|
||||
Future<bool> runEnforcementNow() async =>
|
||||
await channel.invokeMethod<bool>('runEnforcementNow') ?? false;
|
||||
|
||||
/// Cancels any pending scheduled evaluation.
|
||||
Future<bool> cancelEnforcement() async =>
|
||||
await channel.invokeMethod<bool>('cancelEnforcement') ?? false;
|
||||
|
||||
/// Relinquishes device ownership without wiping the device.
|
||||
///
|
||||
/// This is the escape hatch. It is deliberately reachable from the app's
|
||||
|
||||
@ -111,6 +111,22 @@ class _StatusPageState extends State<StatusPage> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _runNow() async {
|
||||
setState(() => _busy = true);
|
||||
final started = await widget.policy.runEnforcementNow();
|
||||
if (!mounted) return;
|
||||
setState(() => _busy = false);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
started
|
||||
? 'Enforcement run started; schedule armed'
|
||||
: 'Could not start enforcement',
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _release() async {
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
@ -164,6 +180,7 @@ class _StatusPageState extends State<StatusPage> {
|
||||
policyError: _policyError,
|
||||
busy: _busy,
|
||||
onRelease: _release,
|
||||
onRunNow: _runNow,
|
||||
);
|
||||
} else {
|
||||
body = const Center(child: CircularProgressIndicator());
|
||||
@ -194,6 +211,7 @@ class _StatusBody extends StatelessWidget {
|
||||
required this.policyError,
|
||||
required this.busy,
|
||||
required this.onRelease,
|
||||
required this.onRunNow,
|
||||
});
|
||||
|
||||
final DevicePolicyStatus status;
|
||||
@ -201,6 +219,7 @@ class _StatusBody extends StatelessWidget {
|
||||
final String? policyError;
|
||||
final bool busy;
|
||||
final Future<void> Function() onRelease;
|
||||
final Future<void> Function() onRunNow;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@ -248,6 +267,11 @@ class _StatusBody extends StatelessWidget {
|
||||
),
|
||||
],
|
||||
const SizedBox(height: _kGap * 2),
|
||||
OutlinedButton(
|
||||
onPressed: busy ? null : onRunNow,
|
||||
child: const Text('Run enforcement now'),
|
||||
),
|
||||
const SizedBox(height: _kGap),
|
||||
if (status.isDeviceOwner)
|
||||
FilledButton(
|
||||
onPressed: busy ? null : onRelease,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user