diff --git a/README.md b/README.md index a43aa2f..5c4317e 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,7 @@ Here **_> 1 minute tasks' scheduler_** and **_Immediate tasks' scheduler_** take ### Android -Plugin supports devices running Android 5.1 Lollipop (SDK 22) to Android 10 Q (SDK 29). Given that this plugin supports last Android 10 changes in foreground services, **Android Build Tools 29.x.x+ is required**. +Plugin supports devices running Android 5.1 Lollipop (SDK 22) to Android 12 S (SDK 31). Given that this plugin relies on exact alarms to schedule tasks in time and supports last Android 12 changes regarding exact alarm scheduling, **Android Build Tools 31.x.x+ are required**. ### iOS diff --git a/demo/app/tests/internal/tasks/schedulers/time-based/android/exact-alarm-perms-manager.android.spec.ts b/demo/app/tests/internal/tasks/schedulers/time-based/android/exact-alarm-perms-manager.android.spec.ts new file mode 100644 index 0000000..d6a1719 --- /dev/null +++ b/demo/app/tests/internal/tasks/schedulers/time-based/android/exact-alarm-perms-manager.android.spec.ts @@ -0,0 +1,65 @@ +import { ExactAlarmPermsManager } from "nativescript-task-dispatcher/internal/tasks/schedulers/time-based/android/alarms/exact-alarm-perms-manager.android"; +import { + createOsAlarmManagerMock, + createOsForegroundActivityMock, + isSdkBelow, +} from "~/tests/internal/tasks/schedulers/time-based/android/index"; +import { createScheduleExactAlarmPermRequestIntent } from "nativescript-task-dispatcher/internal/tasks/schedulers/time-based/android/intents.android"; +import { Utils } from "@nativescript/core"; + +describe("Exact alarm perms manager", () => { + if (typeof android === "undefined") { + return; + } + const alarmManagerMock = createOsAlarmManagerMock(); + const foregroundActivityMock = createOsForegroundActivityMock(); + beforeEach(() => { + spyOn(alarmManagerMock, "canScheduleExactAlarms").and.returnValue( + false + ); + spyOn(foregroundActivityMock, "startActivity"); + }); + + it("scheduling exact alarms is allowed by default when api level is lower than 31", () => { + const exactAlarmPermsManager = new ExactAlarmPermsManager( + alarmManagerMock, + 30 + ); + + const isGranted = exactAlarmPermsManager.isGranted(); + + expect(isGranted).toBeTruthy(); + expect(alarmManagerMock.canScheduleExactAlarms).not.toHaveBeenCalled(); + }); + + it("checks if schedule exact alarm permission is granted", () => { + if (isSdkBelow(31)) return; + + const exactAlarmPermsManager = new ExactAlarmPermsManager( + alarmManagerMock + ); + + const isGranted = exactAlarmPermsManager.isGranted(); + + expect(isGranted).toBeFalse(); + expect(alarmManagerMock.canScheduleExactAlarms).toHaveBeenCalled(); + }); + + it("requests the permission when it has to", () => { + if (isSdkBelow(31)) return; + + const exactAlarmPermsManager = new ExactAlarmPermsManager( + alarmManagerMock, + android.os.Build.VERSION.SDK_INT, + () => foregroundActivityMock + ); + + exactAlarmPermsManager.request(); + + expect(foregroundActivityMock.startActivity).toHaveBeenCalledOnceWith( + createScheduleExactAlarmPermRequestIntent( + Utils.android.getApplicationContext().getPackageName() + ) + ); + }); +}); diff --git a/demo/app/tests/internal/tasks/schedulers/time-based/android/index.ts b/demo/app/tests/internal/tasks/schedulers/time-based/android/index.ts index 132d1d3..eedafb9 100644 --- a/demo/app/tests/internal/tasks/schedulers/time-based/android/index.ts +++ b/demo/app/tests/internal/tasks/schedulers/time-based/android/index.ts @@ -24,7 +24,22 @@ export function createOsAlarmManagerMock(): android.app.AlarmManager { cancel(p0: android.app.PendingIntent): void { return; }, + canScheduleExactAlarms(): boolean { + return true; + }, }; return alarmManager as android.app.AlarmManager; } + +export function createOsForegroundActivityMock(): android.app.Activity { + const foregroundActivity = { + startActivity(param: android.content.Intent) {}, + }; + + return foregroundActivity as android.app.Activity; +} + +export function isSdkBelow(version: number) { + return android && android.os.Build.VERSION.SDK_INT < version; +} diff --git a/demo/app/tests/internal/tasks/schedulers/time-based/android/power-savings-manager.android.spec.ts b/demo/app/tests/internal/tasks/schedulers/time-based/android/power-savings-manager.android.spec.ts index 2f82f19..ef3c53b 100644 --- a/demo/app/tests/internal/tasks/schedulers/time-based/android/power-savings-manager.android.spec.ts +++ b/demo/app/tests/internal/tasks/schedulers/time-based/android/power-savings-manager.android.spec.ts @@ -1,39 +1,67 @@ import { PowerSavingsManager } from "nativescript-task-dispatcher/internal/tasks/schedulers/time-based/android/alarms/power-savings-manager.android"; +import { createOsForegroundActivityMock, isSdkBelow } from "./index"; +import { Utils } from "@nativescript/core"; +import { createSavingsDeactivationIntent } from "nativescript-task-dispatcher/internal/tasks/schedulers/time-based/android/intents.android"; describe("Power savings manager", () => { if (typeof android === "undefined") { return; } - const osPowerManager = createOsPowerManagerMock(); + const powerManagerMock = createOsPowerManagerMock(); + const foregroundActivityMock = createOsForegroundActivityMock(); beforeEach(() => { spyOn( - osPowerManager, + powerManagerMock, "isIgnoringBatteryOptimizations" - ).and.callThrough(); + ).and.returnValue(false); + spyOn(foregroundActivityMock, "startActivity"); }); - it("checks if power savings are enabled", () => { - const powerSavingsManager = new PowerSavingsManager(osPowerManager); + it("savings are disabled by default when api level is lower than 23", () => { + const powerSavingsManager = new PowerSavingsManager( + powerManagerMock, + 22 + ); const areDisabled = powerSavingsManager.areDisabled(); - expect(areDisabled === true || areDisabled === false).toBeTruthy(); + expect(areDisabled).toBeTruthy(); expect( - osPowerManager.isIgnoringBatteryOptimizations - ).toHaveBeenCalled(); + powerManagerMock.isIgnoringBatteryOptimizations + ).not.toHaveBeenCalled(); }); - it("returns true by default when api level is lower than 23", () => { - const powerSavingsManager = new PowerSavingsManager(osPowerManager, 22); + it("checks if power savings are enabled", () => { + if (isSdkBelow(23)) return; + + const powerSavingsManager = new PowerSavingsManager(powerManagerMock); const areDisabled = powerSavingsManager.areDisabled(); - expect(areDisabled).toBeTruthy(); + expect(areDisabled).toBeFalse(); expect( - osPowerManager.isIgnoringBatteryOptimizations - ).not.toHaveBeenCalled(); + powerManagerMock.isIgnoringBatteryOptimizations + ).toHaveBeenCalled(); + }); + + it("requests to disable savings when it has to", () => { + if (isSdkBelow(23)) return; + + const powerSavingsManager = new PowerSavingsManager( + powerManagerMock, + android.os.Build.VERSION.SDK_INT, + () => foregroundActivityMock + ); + + powerSavingsManager.requestDeactivation(); + + expect(foregroundActivityMock.startActivity).toHaveBeenCalledOnceWith( + createSavingsDeactivationIntent( + Utils.android.getApplicationContext().getPackageName() + ) + ); }); }); diff --git a/src-native/android/tdproject/taskdispatcher/src/main/AndroidManifest.xml b/src-native/android/tdproject/taskdispatcher/src/main/AndroidManifest.xml index 70ae673..cfbcc07 100644 --- a/src-native/android/tdproject/taskdispatcher/src/main/AndroidManifest.xml +++ b/src-native/android/tdproject/taskdispatcher/src/main/AndroidManifest.xml @@ -15,6 +15,7 @@ android:exported="true"> + diff --git a/src/internal/tasks/schedulers/time-based/android/alarms/exact-alarm-perms-manager.android.ts b/src/internal/tasks/schedulers/time-based/android/alarms/exact-alarm-perms-manager.android.ts new file mode 100644 index 0000000..514b560 --- /dev/null +++ b/src/internal/tasks/schedulers/time-based/android/alarms/exact-alarm-perms-manager.android.ts @@ -0,0 +1,64 @@ +import { getLogger, Logger } from "../../../../../utils/logger"; +import { Application, Utils } from "@nativescript/core"; +import { createScheduleExactAlarmPermRequestIntent } from "../intents.android"; +import { waitForActivityResume } from "./perm-request-common"; + +export class ExactAlarmPermsManager { + private logger: Logger; + private readonly appPackage: string; + private askedOnce: boolean; + + constructor( + private alarmManager: android.app.AlarmManager = Utils.android + .getApplicationContext() + .getSystemService(android.content.Context.ALARM_SERVICE), + private skdVersion = android.os.Build.VERSION.SDK_INT, + private activityGetter = () => Application.android.foregroundActivity + ) { + this.logger = getLogger("ExactAlarmPermsManager"); + this.appPackage = Utils.android.getApplicationContext().getPackageName(); + } + + isGranted(): boolean { + if (this.skdVersion < 31) { + return true; + } + + return this.alarmManager.canScheduleExactAlarms(); + } + + async request(): Promise { + if (this.askedOnce || this.isGranted()) { + return; + } + + const visibleActivity = this.activityGetter(); + if (!visibleActivity) { + this.logger.warn("Schedule exact alarms can not be asked in background"); + + return; + } + this.askedOnce = true; + + const activityResume = waitForActivityResume(); + + const intent = createScheduleExactAlarmPermRequestIntent(this.appPackage); + visibleActivity.startActivity(intent); + + await activityResume; + + if (!this.isGranted()) { + throw new Error( + "Schedule exact alarms permission is required for the app to work as expected!" + ); + } + } +} + +let exactAlarmPermsManager: ExactAlarmPermsManager; +export function getExactAlarmPermsManager(): ExactAlarmPermsManager { + if (!exactAlarmPermsManager) { + exactAlarmPermsManager = new ExactAlarmPermsManager(); + } + return exactAlarmPermsManager; +} diff --git a/src/internal/tasks/schedulers/time-based/android/alarms/perm-request-common.ts b/src/internal/tasks/schedulers/time-based/android/alarms/perm-request-common.ts new file mode 100644 index 0000000..660bac4 --- /dev/null +++ b/src/internal/tasks/schedulers/time-based/android/alarms/perm-request-common.ts @@ -0,0 +1,19 @@ +import { Application } from "@nativescript/core"; +import { AndroidApplication } from "@nativescript/core"; + +export function waitForActivityResume(): Promise { + return new Promise((resolve) => { + const resumeHandler = () => { + Application.android.off( + AndroidApplication.activityResumedEvent, + resumeHandler + ); + resolve(); + }; + + Application.android.on( + AndroidApplication.activityResumedEvent, + resumeHandler + ); + }); +} diff --git a/src/internal/tasks/schedulers/time-based/android/alarms/power-savings-manager.android.ts b/src/internal/tasks/schedulers/time-based/android/alarms/power-savings-manager.android.ts index 2759d8d..3bb36c5 100644 --- a/src/internal/tasks/schedulers/time-based/android/alarms/power-savings-manager.android.ts +++ b/src/internal/tasks/schedulers/time-based/android/alarms/power-savings-manager.android.ts @@ -1,6 +1,7 @@ import { Application, Utils } from "@nativescript/core"; import { createSavingsDeactivationIntent } from "../intents.android"; import { Logger, getLogger } from "../../../../../utils/logger"; +import { waitForActivityResume } from "./perm-request-common"; export class PowerSavingsManager { private logger: Logger; @@ -11,35 +12,47 @@ export class PowerSavingsManager { private powerManager: android.os.PowerManager = Utils.android .getApplicationContext() .getSystemService(android.content.Context.POWER_SERVICE), - private skdVersion = android.os.Build.VERSION.SDK_INT + private skdVersion = android.os.Build.VERSION.SDK_INT, + private activityGetter = () => Application.android.foregroundActivity ) { this.logger = getLogger("PowerSavingsManager"); this.appPackage = Utils.android.getApplicationContext().getPackageName(); } + areDisabled(): boolean { + if (this.skdVersion < 23) { + return true; + } + + return this.powerManager.isIgnoringBatteryOptimizations(this.appPackage); + } + // TODO: Evaluate what to do with devices not running Android Stock layer - requestDeactivation(): void { + async requestDeactivation(): Promise { if (this.askedOnce || this.areDisabled()) { return; } - if (!Application.android.foregroundActivity) { + const visibleActivity = this.activityGetter(); + if (!visibleActivity) { this.logger.warn("Battery savings can not be enabled in background"); return; } - this.askedOnce = true; + + const activityResume = waitForActivityResume(); + const intent = createSavingsDeactivationIntent(this.appPackage); - Application.android.foregroundActivity.startActivity(intent); - } + visibleActivity.startActivity(intent); - areDisabled(): boolean { - if (this.skdVersion < 23) { - return true; - } + await activityResume; - return this.powerManager.isIgnoringBatteryOptimizations(this.appPackage); + if (!this.areDisabled()) { + throw new Error( + "Disabling battery optimizations is required for the app to work as expected!" + ); + } } } diff --git a/src/internal/tasks/schedulers/time-based/android/boot-receiver.android.ts b/src/internal/tasks/schedulers/time-based/android/boot-receiver.android.ts index 3c11808..0a09f72 100644 --- a/src/internal/tasks/schedulers/time-based/android/boot-receiver.android.ts +++ b/src/internal/tasks/schedulers/time-based/android/boot-receiver.android.ts @@ -1,5 +1,6 @@ import { AndroidAlarmScheduler } from "./alarms/alarm/scheduler.android"; import { getLogger } from "../../../../utils/logger"; +import { getExactAlarmPermsManager } from "./alarms/exact-alarm-perms-manager.android"; export class BootReceiver implements es.uji.geotec.taskdispatcher.BootReceiverDelegate { @@ -7,6 +8,13 @@ export class BootReceiver const logger = getLogger("BootReceiver"); logger.info("Performing boot initializations"); + if (!getExactAlarmPermsManager().isGranted()) { + logger.error( + "Exact alarm permission not granted! Cancelling alarm setup" + ); + return; + } + const alarmScheduler = new AndroidAlarmScheduler(); alarmScheduler .setup() diff --git a/src/internal/tasks/schedulers/time-based/android/intents.android.ts b/src/internal/tasks/schedulers/time-based/android/intents.android.ts index 77d52b0..ca55334 100644 --- a/src/internal/tasks/schedulers/time-based/android/intents.android.ts +++ b/src/internal/tasks/schedulers/time-based/android/intents.android.ts @@ -31,6 +31,13 @@ export function createSavingsDeactivationIntent(appPackage: string) { ); } +export function createScheduleExactAlarmPermRequestIntent(appPackage: string) { + return new android.content.Intent( + android.provider.Settings.ACTION_REQUEST_SCHEDULE_EXACT_ALARM, + android.net.Uri.parse(`package:${appPackage}`) + ); +} + const ARS_RUN_IN_FOREGROUND = "foreground"; const ARS_TIME_OFFSET = "time-offset"; const ARS_INVOCATION_TIME = "invocation-time"; diff --git a/src/platforms/android/taskdispatcher-release.aar b/src/platforms/android/taskdispatcher-release.aar index d067d70..e09c5d2 100644 Binary files a/src/platforms/android/taskdispatcher-release.aar and b/src/platforms/android/taskdispatcher-release.aar differ diff --git a/src/references.d.ts b/src/references.d.ts index c345a95..2d2a2d6 100644 --- a/src/references.d.ts +++ b/src/references.d.ts @@ -1,4 +1,4 @@ -/// +/// /// /// diff --git a/src/task-dispatcher.android.ts b/src/task-dispatcher.android.ts index 8b4a4fd..d53d4e8 100644 --- a/src/task-dispatcher.android.ts +++ b/src/task-dispatcher.android.ts @@ -2,7 +2,6 @@ import { Common, ConfigParams } from "./task-dispatcher.common"; import { Task } from "./tasks"; import { TaskGraph } from "./tasks/graph"; -import { taskGraph } from "./internal/tasks/graph/loader"; import { taskGraphBrowser } from "./internal/tasks/graph/browser"; import { setTaskSchedulerCreator } from "./internal/tasks/schedulers/time-based/common"; @@ -15,6 +14,7 @@ import { getAlarmRunnerService } from "./internal/tasks/schedulers/time-based/an import { getWatchDogReceiver } from "./internal/tasks/schedulers/time-based/android/alarms/watchdog/receiver.android"; import { getTaskChainRunnerService } from "./internal/tasks/schedulers/event-driven/android/runner-service.android"; import { getPowerSavingsManager } from "./internal/tasks/schedulers/time-based/android/alarms/power-savings-manager.android"; +import { getExactAlarmPermsManager } from "./internal/tasks/schedulers/time-based/android/alarms/exact-alarm-perms-manager.android"; const BATTERY_OPTIMIZATIONS_THRESHOLD = 15 * 60; @@ -35,12 +35,19 @@ class TaskDispatcher extends Common { if (this.requiresDisablingBatteryOptimizations()) { return Promise.resolve(false); } + if (!getExactAlarmPermsManager().isGranted()) { + return Promise.resolve(false); + } return super.isReady(); } - prepare(): Promise { + async prepare(): Promise { if (this.requiresDisablingBatteryOptimizations()) { - this.requestToDisableBatteryOptimizations(); + await this.requestToDisableBatteryOptimizations(); + } + const exactAlarmPermsManager = getExactAlarmPermsManager(); + if (!exactAlarmPermsManager.isGranted()) { + await exactAlarmPermsManager.request(); } return super.prepare(); } @@ -58,12 +65,12 @@ class TaskDispatcher extends Common { return false; } - private requestToDisableBatteryOptimizations() { + private async requestToDisableBatteryOptimizations() { const manager = getPowerSavingsManager(); if (manager.areDisabled()) { return; } - manager.requestDeactivation(); + await manager.requestDeactivation(); } private wireUpNativeComponents() {