From 017579f63b2ad90de567d23a7bea0d77d01a9c65 Mon Sep 17 00:00:00 2001 From: Christian Landvogt Date: Sat, 3 Feb 2024 21:48:52 +0100 Subject: [PATCH 01/12] Add CronAPI This implements a cron-like API which uses AlarmManager and WorkManager WorkManager is used to keep phone awake during task execution, and to be able to handle constraints - including stopping the task if constraint is no longer met --- app/build.gradle | 10 + app/src/main/AndroidManifest.xml | 24 +- .../com/termux/api/TermuxAPIApplication.java | 17 +- .../com/termux/api/TermuxAPIConstants.java | 6 +- .../com/termux/api/TermuxApiReceiver.java | 4 + .../java/com/termux/api/apis/CronAPI.java | 93 +++++++ .../com/termux/api/apis/JobSchedulerAPI.java | 2 + .../com/termux/api/cron/CronBootReceiver.java | 24 ++ .../java/com/termux/api/cron/CronEntry.java | 259 ++++++++++++++++++ .../com/termux/api/cron/CronReceiver.java | 54 ++++ .../com/termux/api/cron/CronScheduler.java | 158 +++++++++++ .../java/com/termux/api/cron/CronTab.java | 157 +++++++++++ .../java/com/termux/api/cron/CronWorker.java | 169 ++++++++++++ 13 files changed, 972 insertions(+), 5 deletions(-) create mode 100644 app/src/main/java/com/termux/api/apis/CronAPI.java create mode 100644 app/src/main/java/com/termux/api/cron/CronBootReceiver.java create mode 100644 app/src/main/java/com/termux/api/cron/CronEntry.java create mode 100644 app/src/main/java/com/termux/api/cron/CronReceiver.java create mode 100644 app/src/main/java/com/termux/api/cron/CronScheduler.java create mode 100644 app/src/main/java/com/termux/api/cron/CronTab.java create mode 100644 app/src/main/java/com/termux/api/cron/CronWorker.java diff --git a/app/build.gradle b/app/build.gradle index 21d13baf7..1ebabe70d 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -44,6 +44,8 @@ android { compileOptions { sourceCompatibility JavaVersion.VERSION_1_8 targetCompatibility JavaVersion.VERSION_1_8 + + coreLibraryDesugaringEnabled true } applicationVariants.all { variant -> @@ -64,9 +66,17 @@ android { } dependencies { + coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:1.2.3' + implementation 'com.google.android.material:material:1.4.0' implementation 'androidx.biometric:biometric:1.2.0-alpha03' + implementation "androidx.work:work-runtime:2.6.0" + implementation "androidx.concurrent:concurrent-futures:1.1.0" implementation 'androidx.media:media:1.4.3' + implementation 'com.cronutils:cron-utils:9.2.0' + implementation 'com.google.code.gson:gson:2.10.1' + implementation "com.google.guava:guava:24.1-android" + implementation 'com.google.guava:listenablefuture:9999.0-empty-to-avoid-conflict-with-guava' implementation 'com.termux.termux-app:termux-shared:8c1749ef96' // Use if below libraries are published locally by termux-app with `./gradlew publishReleasePublicationToMavenLocal` and used with `mavenLocal()`. diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 8411d0003..ea704716f 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -28,6 +28,7 @@ + @@ -141,7 +142,28 @@ android:grantUriPermissions="true" android:exported="true" /> - + + + + + + + + + + + + + diff --git a/app/src/main/java/com/termux/api/TermuxAPIApplication.java b/app/src/main/java/com/termux/api/TermuxAPIApplication.java index 0f2b7d695..7f57ff839 100644 --- a/app/src/main/java/com/termux/api/TermuxAPIApplication.java +++ b/app/src/main/java/com/termux/api/TermuxAPIApplication.java @@ -2,7 +2,9 @@ import android.app.Application; import android.content.Context; - +import android.util.Log; +import androidx.annotation.NonNull; +import androidx.work.Configuration; import com.termux.api.util.ResultReturner; import com.termux.shared.logger.Logger; import com.termux.shared.termux.TermuxConstants; @@ -10,8 +12,9 @@ import com.termux.shared.termux.settings.preferences.TermuxAPIAppSharedPreferences; -public class TermuxAPIApplication extends Application { +public class TermuxAPIApplication extends Application implements Configuration.Provider { + @Override public void onCreate() { super.onCreate(); @@ -28,7 +31,7 @@ public void onCreate() { } public static void setLogConfig(Context context, boolean commitToFile) { - Logger.setDefaultLogTag(TermuxConstants.TERMUX_API_APP_NAME.replaceAll(":", "")); + Logger.setDefaultLogTag(TermuxConstants.TERMUX_API_APP_NAME.replace(":", "")); // Load the log level from shared preferences and set it to the {@link Logger.CURRENT_LOG_LEVEL} TermuxAPIAppSharedPreferences preferences = TermuxAPIAppSharedPreferences.build(context); @@ -36,4 +39,12 @@ public static void setLogConfig(Context context, boolean commitToFile) { preferences.setLogLevel(null, preferences.getLogLevel(true), commitToFile); } + @NonNull + @Override + public Configuration getWorkManagerConfiguration() { + return new Configuration.Builder() + .setJobSchedulerJobIdRange(10_000, 11_000) + .setMinimumLoggingLevel(Log.INFO) + .build(); + } } diff --git a/app/src/main/java/com/termux/api/TermuxAPIConstants.java b/app/src/main/java/com/termux/api/TermuxAPIConstants.java index e27e5b987..6e6bb5ddf 100644 --- a/app/src/main/java/com/termux/api/TermuxAPIConstants.java +++ b/app/src/main/java/com/termux/api/TermuxAPIConstants.java @@ -1,6 +1,5 @@ package com.termux.api; -import com.termux.shared.termux.TermuxConstants; import static com.termux.shared.termux.TermuxConstants.TERMUX_API_PACKAGE_NAME; import static com.termux.shared.termux.TermuxConstants.TERMUX_PACKAGE_NAME; @@ -14,4 +13,9 @@ public class TermuxAPIConstants { /** The Uri authority for Termux:API app file shares */ public static final String TERMUX_API_FILE_SHARE_URI_AUTHORITY = TERMUX_PACKAGE_NAME + ".sharedfiles"; // Default: "com.termux.sharedfiles" + public static final String TERMUX_API_CRON_ALARM_SCHEME = "com.termux.api.cron.alarm"; + + public static final String TERMUX_API_CRON_CONSTRAINT_SCHEME = "com.termux.api.cron.constraint"; + + public static final String TERMUX_API_CRON_EXECUTION_RESULT_SCHEME = "com.termux.api.cron.exec"; } diff --git a/app/src/main/java/com/termux/api/TermuxApiReceiver.java b/app/src/main/java/com/termux/api/TermuxApiReceiver.java index 6efaafe16..4629048eb 100644 --- a/app/src/main/java/com/termux/api/TermuxApiReceiver.java +++ b/app/src/main/java/com/termux/api/TermuxApiReceiver.java @@ -8,6 +8,7 @@ import android.provider.Settings; import android.widget.Toast; +import com.termux.api.apis.CronAPI; import com.termux.api.apis.AudioAPI; import com.termux.api.apis.BatteryStatusAPI; import com.termux.api.apis.BrightnessAPI; @@ -84,6 +85,9 @@ private void doWork(Context context, Intent intent) { } switch (apiMethod) { + case "Cron": + CronAPI.onReceive(this, context, intent); + break; case "AudioInfo": AudioAPI.onReceive(this, context, intent); break; diff --git a/app/src/main/java/com/termux/api/apis/CronAPI.java b/app/src/main/java/com/termux/api/apis/CronAPI.java new file mode 100644 index 000000000..876e2f9e7 --- /dev/null +++ b/app/src/main/java/com/termux/api/apis/CronAPI.java @@ -0,0 +1,93 @@ +package com.termux.api.apis; + +import android.content.Context; +import android.content.Intent; +import com.termux.api.TermuxApiReceiver; +import com.termux.api.cron.CronTab; +import com.termux.api.cron.CronEntry; +import com.termux.api.cron.CronScheduler; +import com.termux.api.util.ResultReturner; +import com.termux.shared.logger.Logger; + +import java.util.List; +import java.util.Locale; + +public class CronAPI { + + public static final String LOG_TAG = "CronAPI"; + + private CronAPI() { + /* static class */ + } + + public static void onReceive(TermuxApiReceiver apiReceiver, Context context, Intent intent) { + Logger.logDebug(LOG_TAG, "onReceive"); + + if (intent.getBooleanExtra("list", false)) { + handleList(apiReceiver, intent); + } else if (intent.getIntExtra("info", -1) != -1) { + handleInfo(apiReceiver, intent, intent.getIntExtra("info", -1)); + } else if (intent.getBooleanExtra("reschedule", false)) { + handleRescheduleAll(apiReceiver, context, intent); + } else if (intent.getBooleanExtra("delete_all", false)) { + handleDeleteAll(apiReceiver, context, intent); + } else if (intent.getIntExtra("delete", -1) != -1) { + handleDelete(apiReceiver, context, intent, intent.getIntExtra("delete", -1)); + } else { + handleAddJob(apiReceiver, context, intent); + } + } + + private static void handleAddJob(TermuxApiReceiver apiReceiver, Context context, Intent intent) { + try { + CronEntry entry = CronTab.add(intent); + CronScheduler.scheduleAlarmForJob(context, entry); + ResultReturner.returnData(apiReceiver, intent, out -> out.println(entry.describe())); + } catch (Exception e) { + Logger.logError(LOG_TAG, e.getMessage()); + Logger.logStackTrace(LOG_TAG, e); + ResultReturner.returnData(apiReceiver, intent, out -> out.println(e.getMessage())); + } + } + + private static void handleList(TermuxApiReceiver apiReceiver, Intent intent) { + ResultReturner.returnData(apiReceiver, intent, out -> out.println(CronTab.print())); + } + + private static void handleInfo(TermuxApiReceiver apiReceiver, Intent intent, int id) { + CronEntry entry = CronTab.getById(id); + if (entry != null) { + ResultReturner.returnData(apiReceiver, intent, out -> out.println(entry.describe())); + } else { + ResultReturner.returnData(apiReceiver, intent, out -> + out.println(String.format(Locale.getDefault(), "Cron job with id %d not found", id))); + } + } + + private static void handleRescheduleAll(TermuxApiReceiver apiReceiver, Context context, Intent intent) { + for (CronEntry entry : CronTab.getAll()) { + CronScheduler.scheduleAlarmForJob(context, entry); + } + ResultReturner.returnData(apiReceiver, intent, out -> out.println("All cron jobs have been rescheduled")); + } + + private static void handleDeleteAll(TermuxApiReceiver apiReceiver, Context context, Intent intent) { + List entries = CronTab.clear(); + for (CronEntry entry : entries) { + CronScheduler.cancelAlarmForJob(context, entry); + } + ResultReturner.returnData(apiReceiver, intent, out -> out.println("All cron jobs deleted")); + } + + private static void handleDelete(TermuxApiReceiver apiReceiver, Context context, Intent intent, int id) { + CronEntry entry = CronTab.delete(id); + if (entry != null) { + CronScheduler.cancelAlarmForJob(context, entry); + ResultReturner.returnData(apiReceiver, intent, out -> + out.println(String.format(Locale.getDefault(), "Deleted cron job with id %d", id))); + } else { + ResultReturner.returnData(apiReceiver, intent, out -> + out.println(String.format(Locale.getDefault(), "Cron job with id %d not found?", id))); + } + } +} diff --git a/app/src/main/java/com/termux/api/apis/JobSchedulerAPI.java b/app/src/main/java/com/termux/api/apis/JobSchedulerAPI.java index eb7977938..79ae09b68 100644 --- a/app/src/main/java/com/termux/api/apis/JobSchedulerAPI.java +++ b/app/src/main/java/com/termux/api/apis/JobSchedulerAPI.java @@ -1,5 +1,6 @@ package com.termux.api.apis; +import android.annotation.SuppressLint; import android.app.job.JobInfo; import android.app.job.JobParameters; import android.app.job.JobScheduler; @@ -208,6 +209,7 @@ private static void cancelJob(TermuxApiReceiver apiReceiver, Intent intent, JobS + @SuppressLint("SpecifyJobSchedulerIdRange") public static class JobSchedulerService extends JobService { public static final String SCRIPT_FILE_PATH = TermuxConstants.TERMUX_API_PACKAGE_NAME + ".jobscheduler_script_path"; diff --git a/app/src/main/java/com/termux/api/cron/CronBootReceiver.java b/app/src/main/java/com/termux/api/cron/CronBootReceiver.java new file mode 100644 index 000000000..53918e4bf --- /dev/null +++ b/app/src/main/java/com/termux/api/cron/CronBootReceiver.java @@ -0,0 +1,24 @@ +package com.termux.api.cron; + +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import com.termux.shared.data.IntentUtils; +import com.termux.shared.logger.Logger; + +public class CronBootReceiver extends BroadcastReceiver { + + private static final String LOG_TAG = "CronBootReceiver"; + + @Override + public void onReceive(Context context, Intent intent) { + if (!intent.getAction().equals(Intent.ACTION_BOOT_COMPLETED)) { + Logger.logDebug(LOG_TAG, "Unknown intent Received:\n" + IntentUtils.getIntentString(intent)); + return; + } + + for (CronEntry entry : CronTab.getAll()) { + CronScheduler.scheduleAlarmForJob(context, entry); + } + } +} diff --git a/app/src/main/java/com/termux/api/cron/CronEntry.java b/app/src/main/java/com/termux/api/cron/CronEntry.java new file mode 100644 index 000000000..6656bdf9b --- /dev/null +++ b/app/src/main/java/com/termux/api/cron/CronEntry.java @@ -0,0 +1,259 @@ +package com.termux.api.cron; + +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.net.Uri; +import androidx.annotation.NonNull; +import androidx.work.Constraints; +import androidx.work.NetworkType; +import com.cronutils.descriptor.CronDescriptor; +import com.cronutils.model.Cron; +import com.cronutils.model.CronType; +import com.cronutils.model.definition.CronDefinitionBuilder; +import com.cronutils.model.time.ExecutionTime; +import com.cronutils.parser.CronParser; + +import java.io.File; +import java.time.ZonedDateTime; +import java.time.format.DateTimeFormatter; +import java.time.format.FormatStyle; +import java.util.Locale; +import java.util.Optional; + +import static com.termux.api.TermuxAPIConstants.TERMUX_API_CRON_ALARM_SCHEME; +import static com.termux.api.TermuxAPIConstants.TERMUX_API_CRON_CONSTRAINT_SCHEME; +import static com.termux.shared.termux.TermuxConstants.TERMUX_API_PACKAGE_NAME; + +public class CronEntry { + + private static final CronParser cronParser = new CronParser(CronDefinitionBuilder.instanceDefinitionFor(CronType.UNIX)); + private static final CronDescriptor descriptor = CronDescriptor.instance(Locale.UK); + + private final int id; + private final String cronExpression; + private final String scriptPath; + + private final boolean exact; + + private final NetworkType networkType; + private final boolean batteryNotLow; + private final boolean charging; + private final boolean deviceIdle; + private final boolean storageNotLow; + private final long constraintTimeout; + private final boolean continueOnConstraint; + private final long maxRuntime; + private final int gracePeriod; + + private CronEntry(int id, String cronExpression, String scriptPath, boolean exact, + NetworkType networkType, boolean batteryNotLow, boolean charging, + boolean deviceIdle, boolean storageNotLow, long constraintTimeout, + int gracePeriod, boolean continueOnConstraint, long maxRuntime) { + this.id = id; + this.cronExpression = cronExpression; + this.scriptPath = scriptPath; + this.exact = exact; + this.networkType = networkType; + this.batteryNotLow = batteryNotLow; + this.charging = charging; + this.deviceIdle = deviceIdle; + this.storageNotLow = storageNotLow; + this.constraintTimeout = constraintTimeout; + this.continueOnConstraint = continueOnConstraint; + this.maxRuntime = maxRuntime; + this.gracePeriod = gracePeriod; + } + + public int getId() { + return id; + } + + public String getScriptPath() { + return scriptPath; + } + + public boolean isExact() { + return exact; + } + + public long getConstraintTimeout() { + return constraintTimeout; + } + + public long getMaxRuntime() { + return maxRuntime; + } + + public boolean continueOnFailingConstraint() { + return continueOnConstraint; + } + + public Constraints getConstraints() { + return new Constraints.Builder() + .setRequiredNetworkType(networkType) + .setRequiresBatteryNotLow(batteryNotLow) + .setRequiresCharging(charging) + .setRequiresDeviceIdle(deviceIdle) + .setRequiresStorageNotLow(storageNotLow) + .build(); + } + + public boolean hasNoConstraints() { + return networkType == NetworkType.NOT_REQUIRED + && !batteryNotLow + && !charging + && !deviceIdle + && !storageNotLow; + } + + public int getGracePeriod() { + return gracePeriod; + } + + public Intent getIntent(Context context, Class receiver) { + Uri uri = new Uri.Builder() + .scheme(TERMUX_API_CRON_ALARM_SCHEME) + .appendPath("id") + .appendPath(String.valueOf(id)) + .build(); + + Intent intent = new Intent(context, receiver); + intent.setData(uri); + intent.putExtra(TERMUX_API_PACKAGE_NAME + ".cron.expression", cronExpression); + intent.putExtra(TERMUX_API_PACKAGE_NAME + ".cron.script", scriptPath); + + return intent; + } + + public Intent getTimeoutIntent(Context context, Class receiver) { + Intent intent = new Intent(context, receiver); + intent.setData(new Uri.Builder() + .scheme(TERMUX_API_CRON_CONSTRAINT_SCHEME) + .appendPath("id") + .appendPath(String.valueOf(id)) + .build()); + + return intent; + } + + public long getNextExecutionTime() { + ExecutionTime executionTime = ExecutionTime.forCron(cronParser.parse(cronExpression)); + Optional nextExecution = executionTime.nextExecution(ZonedDateTime.now()); + if (!nextExecution.isPresent()) { + throw new IllegalStateException(String.format("Failing to calculate next execution time for job %d", id)); + } + return nextExecution.get().toEpochSecond() * 1000L; + } + + public static CronEntry fromIntent(Intent intent, int newId) { + int id = intent.getIntExtra("job_id", newId); + + String cronExpression = getNonEmptyStringFromIntent(intent, "cron"); + Cron parse = cronParser.parse(cronExpression); + parse.validate(); + + String scriptPath = validateScriptPath(intent); + + final boolean exact = intent.getBooleanExtra("exact", false); + + String networkTypeString = intent.getStringExtra("network"); + final boolean batteryNotLow = intent.getBooleanExtra("battery_not_low", false); + final boolean charging = intent.getBooleanExtra("charging", false); + final boolean deviceIdle = intent.getBooleanExtra("idle", false); + final boolean storageNotLow = intent.getBooleanExtra("storage_not_low", false); + + NetworkType networkType = networkTypeString == null + ? NetworkType.NOT_REQUIRED + : NetworkType.valueOf(networkTypeString.toUpperCase()); + + long constraintTimeout = intent.getIntExtra("constraint_timeout", 60) * 1_000L; + int gracePeriod = intent.getIntExtra("grace_period", 5_000); + boolean continueOnConstraints = intent.getBooleanExtra("constraint_continue", false); + long maxRuntime = intent.getIntExtra("max_runtime", 60) * 1_000L; + + return new CronEntry(id, cronExpression, scriptPath, exact, networkType, batteryNotLow, charging, + deviceIdle, storageNotLow, constraintTimeout, gracePeriod, continueOnConstraints, maxRuntime); + } + + private static String validateScriptPath(Intent intent) { + String script = getNonEmptyStringFromIntent(intent, "script"); + String path = getNonEmptyStringFromIntent(intent, "path"); + + String scriptPath; + if (!script.startsWith("/")) { + scriptPath = String.format("%s/%s", path, script); + } else { + scriptPath = script; + } + + File file = new File(scriptPath); + if (!file.isFile() || !file.canRead() || !file.canExecute()) { + throw new IllegalArgumentException(scriptPath + " is either missing or cannot be executed!"); + } + + return scriptPath; + } + + private static String getNonEmptyStringFromIntent(Intent intent, String name) { + String stringExtra = intent.getStringExtra(name); + if (stringExtra == null || stringExtra.isEmpty()) { + throw new IllegalArgumentException(String.format("Parameter %s is required", name)); + } + return stringExtra; + } + + @NonNull + @Override + public String toString() { + return CronTab.gson.toJson(this); + } + + private String getConstraintsCrontabEntry() { + if (hasNoConstraints() && !exact) { + return ""; + } + + String networkString = networkType != NetworkType.NOT_REQUIRED ? networkType.toString().charAt(0) + "-" : ""; + return String.format("%s%s%s%s%s%s", + networkString, + batteryNotLow ? "B" : "", + charging ? "C" : "", + deviceIdle ? "I" : "", + storageNotLow ? "S" : "", + exact ? "!" : ""); + } + + public String toListEntry() { + return String.format(Locale.getDefault(), "%4d | %15s | %8s | %s", id, cronExpression, getConstraintsCrontabEntry(), scriptPath); + } + + public String describe() { + String exactWarning = exact && !hasNoConstraints() ? "When constraints are used exact scheduling is probably unnecessary!\n" : ""; + + String runtimeString = maxRuntime > 0 ? (maxRuntime / 1000) + " seconds" : "no limit"; + String timeoutString = constraintTimeout > 0 ? (constraintTimeout / 1000) + " seconds timeout" : "no timeout"; + String constraintContinue = continueOnConstraint ? " (running worker continues)" : ""; + + String constraintString = hasNoConstraints() + ? "none" + : getConstraintsCrontabEntry() + constraintContinue + " " + timeoutString; + + ExecutionTime executionTime = ExecutionTime.forCron(cronParser.parse(cronExpression)); + Optional nextExecution = executionTime.nextExecution(ZonedDateTime.now()); + + return exactWarning + + String.format(Locale.getDefault(), + "%s %s%n", cronExpression, scriptPath) + + String.format(Locale.getDefault(), + "Description: %s%n", descriptor.describe(cronParser.parse(cronExpression))) + + String.format(Locale.getDefault(), + "Max runtime: %s - grace period: %d msec%n", runtimeString, gracePeriod) + + String.format(Locale.getDefault(), + "Constraints: %s%n", constraintString) + + String.format(Locale.getDefault(), + "Next run : %s", nextExecution + .map(z -> z.format(DateTimeFormatter.ofLocalizedDateTime(FormatStyle.MEDIUM))) + .orElse("???")); + } +} diff --git a/app/src/main/java/com/termux/api/cron/CronReceiver.java b/app/src/main/java/com/termux/api/cron/CronReceiver.java new file mode 100644 index 000000000..2d7538009 --- /dev/null +++ b/app/src/main/java/com/termux/api/cron/CronReceiver.java @@ -0,0 +1,54 @@ +package com.termux.api.cron; + +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import com.termux.shared.data.IntentUtils; +import com.termux.shared.logger.Logger; + +import java.util.Locale; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CountDownLatch; + +import static com.termux.api.TermuxAPIConstants.*; + +public class CronReceiver extends BroadcastReceiver { + + private static final String LOG_TAG = "CronReceiver"; + + static final Map workerSignals = new ConcurrentHashMap<>(); + + @Override + public void onReceive(Context context, Intent intent) { + Logger.logDebug(LOG_TAG, "onReceive Cron"); + Logger.logDebug(LOG_TAG, "Intent Received:\n" + IntentUtils.getIntentString(intent)); + + int id = Integer.parseInt(intent.getData().getLastPathSegment()); + + switch (intent.getData().getScheme()) { + case TERMUX_API_CRON_ALARM_SCHEME: + CronEntry entry = CronTab.getById(id); + if (entry != null) { + CronScheduler.enqueueWorkRequest(context, entry); + } else { + Logger.logWarn(String.format(Locale.getDefault(), "Cron job with id %d not found", id)); + } + break; + case TERMUX_API_CRON_EXECUTION_RESULT_SCHEME: + CountDownLatch countDownLatch = workerSignals.get(id); + if (countDownLatch != null) { + countDownLatch.countDown(); + } + break; + case TERMUX_API_CRON_CONSTRAINT_SCHEME: + CronScheduler.cancelWorkRequestDueToConstraintTimeout(context, id); + break; + default: + Logger.logError(LOG_TAG, "Unrecognized data URI scheme " + intent.getData().toString()); + } + + // some housekeeping + workerSignals.entrySet().removeIf(e -> e.getValue().getCount() == 0); + } +} diff --git a/app/src/main/java/com/termux/api/cron/CronScheduler.java b/app/src/main/java/com/termux/api/cron/CronScheduler.java new file mode 100644 index 000000000..0e1acfb39 --- /dev/null +++ b/app/src/main/java/com/termux/api/cron/CronScheduler.java @@ -0,0 +1,158 @@ +package com.termux.api.cron; + +import android.app.AlarmManager; +import android.app.PendingIntent; +import android.content.Context; +import android.content.Intent; +import androidx.work.*; +import com.termux.shared.logger.Logger; + +import java.util.List; +import java.util.Locale; + +public class CronScheduler { + + private static final String LOG_TAG = "CronScheduler"; + static final String WORKER_INPUT_ID = "id"; + static final String WORKER_INPUT_SCRIPT = "scriptPath"; + static final String WORKER_INPUT_MAX_RUNTIME = "maxRuntime"; + static final String WORKER_INPUT_CONTINUE = "continue"; + static final String WORKER_INPUT_DELAY = "delay"; + + private CronScheduler() { + /* static class */ + } + + public static void scheduleAlarmForJob(Context context, int id) { + try { + CronEntry entry = CronTab.getById(id); + if (entry != null) { + scheduleAlarmForJob(context, entry); + } else { + Logger.logError(LOG_TAG, String.format(Locale.getDefault(), + "Could not schedule next alarm for job id %d", id)); + } + } catch (Exception e) { + Logger.logError(LOG_TAG, String.format(Locale.getDefault(), + "Could not schedule next alarm for job id %d", id)); + Logger.logStackTrace(e); + } + } + + public static void scheduleAlarmForJob(Context context, CronEntry entry) { + Intent intent = entry.getIntent(context, CronReceiver.class); + PendingIntent pi = PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_CANCEL_CURRENT | PendingIntent.FLAG_IMMUTABLE); + + scheduleAlarm(context, entry.isExact(), entry.getNextExecutionTime(), pi, String.format(Locale.getDefault(), + "Alarm scheduled for job id %d", entry.getId())); + } + + public static void cancelAlarmForJob(Context context, CronEntry entry) { + Intent intent = entry.getIntent(context, CronReceiver.class); + PendingIntent pi = PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_NO_CREATE | PendingIntent.FLAG_IMMUTABLE); + cancelAlarm(context, pi, String.format(Locale.getDefault(), + "Alarm canceled for job id %d", entry.getId())); + } + + public static void enqueueWorkRequest(Context context, CronEntry entry) { + Data inputData = new Data.Builder() + .putInt(WORKER_INPUT_ID, entry.getId()) + .putString(WORKER_INPUT_SCRIPT, entry.getScriptPath()) + .putLong(WORKER_INPUT_MAX_RUNTIME, entry.getMaxRuntime()) + .putBoolean(WORKER_INPUT_CONTINUE, entry.continueOnFailingConstraint()) + .putInt(WORKER_INPUT_DELAY, entry.getGracePeriod()) + .build(); + + OneTimeWorkRequest.Builder builder = new OneTimeWorkRequest.Builder(CronWorker.class) + .setInputData(inputData); + + if (!entry.hasNoConstraints()) { + builder.setConstraints(entry.getConstraints()); + scheduleAlarmForConstraintsTimeout(context, entry); + } + + WorkManager + .getInstance(context) + .enqueueUniqueWork(getUniqueWorkName(entry.getId()), ExistingWorkPolicy.KEEP, builder.build()); + + Logger.logDebug(LOG_TAG, String.format(Locale.getDefault(), + "CronWorker enqueued for job id %d", entry.getId())); + } + + public static void scheduleAlarmForConstraintsTimeout(Context context, CronEntry entry) { + long constraintTimeout = entry.getConstraintTimeout(); + + if (constraintTimeout == 0) { + Logger.logDebug(LOG_TAG, String.format(Locale.getDefault(), + "No constraint timeout scheduled for job id %d!", entry.getId())); + return; + } + + Intent intent = entry.getTimeoutIntent(context, CronReceiver.class); + PendingIntent pi = PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_IMMUTABLE); + long triggerAtMillis = System.currentTimeMillis() + constraintTimeout; + + scheduleAlarm(context, entry.isExact(), triggerAtMillis, pi, String.format(Locale.getDefault(), + "Alarm scheduled for constraint timeout for job id %d", entry.getId())); + } + + public static void cancelAlarmForConstraintsTimeout(Context context, int id) { + CronEntry entry = CronTab.getById(id); + if (entry == null) { + Logger.logError(LOG_TAG, String.format(Locale.getDefault(), + "Could not cancel constraint timeout alarm for job id %d", id)); + return; + } + + Intent intent = entry.getTimeoutIntent(context, CronReceiver.class); + PendingIntent pi = PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_NO_CREATE | PendingIntent.FLAG_IMMUTABLE); + + cancelAlarm(context, pi, String.format(Locale.getDefault(), + "Timeout constraint alarm canceled for job id %d", id)); + } + + public static void cancelWorkRequestDueToConstraintTimeout(Context context, int id) { + String name = getUniqueWorkName(id); + WorkManager workManager = WorkManager + .getInstance(context); + + try { + List workInfoList = workManager.getWorkInfosForUniqueWork(name).get(); + if (workInfoList != null + && !workInfoList.isEmpty() + && (workInfoList.get(0).getState() == WorkInfo.State.ENQUEUED)) { + workManager.cancelUniqueWork(name); + + Logger.logDebug(LOG_TAG, String.format(Locale.getDefault(), + "CronWorker for job id %d canceled due to constraint timeout", id)); + + CronScheduler.scheduleAlarmForJob(context, id); + } + } catch (Exception e) { + Thread.currentThread().interrupt(); + Logger.logStackTrace(LOG_TAG, e); + } + } + + private static String getUniqueWorkName(int id) { + return String.format(Locale.getDefault(), "cron-worker-%d", id); + } + + private static void scheduleAlarm(Context context, boolean exact, long triggerAtMillis, PendingIntent pendingIntent, String logMessage) { + AlarmManager alarmManager = (AlarmManager) context.getSystemService(Context.ALARM_SERVICE); + if (exact) { + alarmManager.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, triggerAtMillis, pendingIntent); + } else { + alarmManager.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, triggerAtMillis, pendingIntent); + } + Logger.logDebug(LOG_TAG, logMessage); + } + + private static void cancelAlarm(Context context, PendingIntent pendingIntent, String logMessage) { + if (pendingIntent != null) { + AlarmManager alarmManager = (AlarmManager) context.getSystemService(Context.ALARM_SERVICE); + alarmManager.cancel(pendingIntent); + Logger.logDebug(LOG_TAG, logMessage); + } + } +} diff --git a/app/src/main/java/com/termux/api/cron/CronTab.java b/app/src/main/java/com/termux/api/cron/CronTab.java new file mode 100644 index 000000000..f77caa967 --- /dev/null +++ b/app/src/main/java/com/termux/api/cron/CronTab.java @@ -0,0 +1,157 @@ +package com.termux.api.cron; + +import android.content.Intent; +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.reflect.TypeToken; +import com.termux.shared.logger.Logger; +import com.termux.shared.termux.TermuxConstants; + +import java.io.*; +import java.lang.reflect.Type; +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; +import java.util.Collections; +import java.util.List; +import java.util.StringJoiner; + +public class CronTab { + + private static final String CRON_TAB_JSON_FILE = TermuxConstants.TERMUX_HOME_DIR_PATH + "/.crontab.json"; + private static final Charset DEFAULT_CHARSET = StandardCharsets.UTF_8; + private static final String LOG_TAG = "CronTab"; + + static final Gson gson = new GsonBuilder().setPrettyPrinting().create(); + + private CronTab() { + /* static class */ + } + + public static List clear() { + List cronEntries = loadFromFile(); + saveToFile(Collections.emptyList()); + return cronEntries; + } + + public static CronEntry add(Intent intent) { + List cronEntries = loadFromFile(); + + int id = cronEntries.stream() + .mapToInt(CronEntry::getId) + .max() + .orElse(0) + 1; + + CronEntry entry = CronEntry.fromIntent(intent, id); + cronEntries.add(entry); + saveToFile(cronEntries); + return entry; + } + + public static List getAll() { + return loadFromFile(); + } + + public static CronEntry delete(int id) { + List cronEntries = loadFromFile(); + CronEntry entry = getByIdOrNull(id, cronEntries); + if (entry == null) { + return null; + } else { + cronEntries.remove(entry); + saveToFile(cronEntries); + return entry; + } + } + + public static CronEntry getById(int id) { + List cronEntries = loadFromFile(); + CronEntry entry = getByIdOrNull(id, cronEntries); + if (entry == null) { + Logger.logWarn(LOG_TAG, String.format("Job with id %s not found!", id)); + return null; + } + return entry; + } + + public static String print() { + List entries = loadFromFile(); + + if (entries.isEmpty()) { + return "==== empty ===="; + } + + StringJoiner sj = new StringJoiner("\n"); + sj.add("Constraints: C- connected | U- unmetered | N- notRoaming | M- metered"); + sj.add("B=batteryNotLow C=charging I=deviceIdle S=storageNotLow !=exact"); + sj.add(""); + sj.add(String.format("%4s | %15s | %8s | %s", "id", "cron", "constr", "script")); + for (CronEntry entry : entries) { + sj.add(entry.toListEntry()); + } + return sj.toString(); + } + + private static CronEntry getByIdOrNull(int id, List entries) { + return entries.stream() + .filter(e -> e.getId() == id) + .findFirst() + .orElse(null); + } + + private static List loadFromFile() { + try { + File file = new File(CRON_TAB_JSON_FILE); + if (!file.exists()) { + return Collections.emptyList(); + } + + String json = getTextFileContents(file); + Type listType = (new TypeToken>() { + }).getType(); + return gson.fromJson(json, listType); + } catch (IOException e) { + throw new IllegalStateException(e); + } + } + + private static void saveToFile(List entries) { + File file = new File(CRON_TAB_JSON_FILE); + String json = gson.toJson(entries); + try { + writeStringToFile(file, json); + } catch (IOException e) { + throw new IllegalStateException(e); + } + } + + private static String getTextFileContents(File file) throws IOException { + try ( + FileInputStream fileInputStream = new FileInputStream(file); + InputStreamReader inputStreamReader = new InputStreamReader(fileInputStream, DEFAULT_CHARSET); + BufferedReader bufferedReader = new BufferedReader(inputStreamReader) + ) { + StringJoiner sj = new StringJoiner("\n"); + + String line = bufferedReader.readLine(); + while (line != null) { + sj.add(line); + line = bufferedReader.readLine(); + } + return sj.toString(); + } + } + + private static void writeStringToFile(File file, String content) throws IOException { + if (!file.exists() && (!file.createNewFile())) { + throw new IOException("File creation failed"); + } + + try ( + FileOutputStream fileOutputStream = new FileOutputStream(file, false); + OutputStreamWriter outputStreamWriter = new OutputStreamWriter(fileOutputStream, DEFAULT_CHARSET); + BufferedWriter bufferedWriter = new BufferedWriter(outputStreamWriter) + ) { + bufferedWriter.write(content); + } + } +} diff --git a/app/src/main/java/com/termux/api/cron/CronWorker.java b/app/src/main/java/com/termux/api/cron/CronWorker.java new file mode 100644 index 000000000..72752ac87 --- /dev/null +++ b/app/src/main/java/com/termux/api/cron/CronWorker.java @@ -0,0 +1,169 @@ +package com.termux.api.cron; + +import android.annotation.SuppressLint; +import android.app.PendingIntent; +import android.content.Context; +import android.content.Intent; +import android.net.Uri; +import android.os.Build; +import androidx.annotation.NonNull; +import androidx.work.Data; +import androidx.work.Worker; +import androidx.work.WorkerParameters; +import com.termux.shared.logger.Logger; +import com.termux.shared.shell.command.ExecutionCommand; +import com.termux.shared.termux.TermuxConstants; + +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; + +import static com.termux.api.TermuxAPIConstants.TERMUX_API_CRON_EXECUTION_RESULT_SCHEME; +import static com.termux.api.cron.CronScheduler.*; + +public class CronWorker extends Worker { + + private static final String LOG_TAG = "CronWorker"; + + private Uri executableUri; + private int jobId; + private long maxRuntime; + private boolean continueOnConstraints; + private int gracePeriod; + + public CronWorker(@NonNull Context appContext, @NonNull WorkerParameters workerParams) { + super(appContext, workerParams); + } + + @NonNull + @Override + public Result doWork() { + Logger.logDebug(LOG_TAG, getId() + " Work started"); + + handleInputData(); + + CronScheduler.cancelAlarmForConstraintsTimeout(getApplicationContext(), jobId); + sendStartIntent(); + + CountDownLatch doneSignal = new CountDownLatch(1); + CronReceiver.workerSignals.put(jobId, doneSignal); + + try { + Logger.logDebug(LOG_TAG, getId() + " Waiting for Termux to finish"); + + boolean hasFinished; + if (maxRuntime == 0) { + doneSignal.await(); + hasFinished = true; + } else { + hasFinished = doneSignal.await(maxRuntime, TimeUnit.SECONDS); + } + + if (isStopped()) { + return Result.failure(); + } + + if (hasFinished) { + Logger.logDebug(LOG_TAG, getId() + " Work finished"); + scheduleNextExecution(); + return Result.success(); + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + + Logger.logDebug(LOG_TAG, getId() + " Work aborted or other error"); + + sendKillIntent(); + scheduleNextExecution(); + return Result.failure(); + } + + private void handleInputData() { + Data inputData = getInputData(); + + jobId = inputData.getInt(WORKER_INPUT_ID, -1); + if (jobId == -1) { + throw new IllegalArgumentException("id should be set!"); + } + + String scriptPath = inputData.getString(WORKER_INPUT_SCRIPT); + this.executableUri = new Uri.Builder() + .scheme(TermuxConstants.TERMUX_APP.TERMUX_SERVICE.URI_SCHEME_SERVICE_EXECUTE) + .path(scriptPath) + .build(); + + maxRuntime = inputData.getLong(WORKER_INPUT_MAX_RUNTIME, 3600); + continueOnConstraints = inputData.getBoolean(WORKER_INPUT_CONTINUE, false); + gracePeriod = inputData.getInt(WORKER_INPUT_DELAY, 5000); + } + + @Override + public void onStopped() { + if (continueOnConstraints) { + Logger.logDebug(LOG_TAG, getId() + " Constraints no longer apply - continuing anyway!"); + return; + } + + Logger.logDebug(LOG_TAG, getId() + " Work stopped!"); + + sendKillIntent(); + scheduleNextExecution(); + + CountDownLatch countDownLatch = CronReceiver.workerSignals.get(jobId); + if (countDownLatch != null) { + countDownLatch.countDown(); + } + } + + private void sendStartIntent() { + ExecutionCommand executionCommand = new ExecutionCommand(); + executionCommand.runner = ExecutionCommand.Runner.APP_SHELL.getName(); + executionCommand.executableUri = executableUri; + + // Create pendingIntent so the result is reported back + Intent resultIntent = new Intent(getApplicationContext(), CronReceiver.class); + resultIntent.setData(new Uri.Builder() + .scheme(TERMUX_API_CRON_EXECUTION_RESULT_SCHEME) + .appendPath("id") + .appendPath(String.valueOf(jobId)) + .build()); + @SuppressLint("UnspecifiedImmutableFlag") + PendingIntent pi = PendingIntent.getBroadcast(getApplicationContext(), 0, resultIntent, 0); + + // Create execution intent with the action TERMUX_SERVICE#ACTION_SERVICE_EXECUTE to be sent to the TERMUX_SERVICE + Intent intent = new Intent(TermuxConstants.TERMUX_APP.TERMUX_SERVICE.ACTION_SERVICE_EXECUTE, executionCommand.executableUri); + intent.setClassName(TermuxConstants.TERMUX_PACKAGE_NAME, TermuxConstants.TERMUX_APP.TERMUX_SERVICE_NAME); + intent.putExtra(TermuxConstants.TERMUX_APP.TERMUX_SERVICE.EXTRA_RUNNER, executionCommand.runner); + intent.putExtra(TermuxConstants.TERMUX_APP.TERMUX_SERVICE.EXTRA_BACKGROUND, true); // Also pass in case user using termux-app version < 0.119.0 + intent.putExtra(TermuxConstants.TERMUX_APP.TERMUX_SERVICE.EXTRA_PENDING_INTENT, pi); + + Context context = getApplicationContext(); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + // https://developer.android.com/about/versions/oreo/background.html + context.startForegroundService(intent); + } else { + context.startService(intent); + } + } + + private void sendKillIntent() { + // Create execution intent with the action TERMUX_SERVICE#ACTION_SERVICE_EXECUTE to be sent to the TERMUX_SERVICE + // needs to be replaced with TermuxConstants.ACTION_SERVICE_STOP + Intent intent = new Intent("com.termux.service_execution_stop", executableUri); + intent.setClassName(TermuxConstants.TERMUX_PACKAGE_NAME, TermuxConstants.TERMUX_APP.TERMUX_SERVICE_NAME); + // needs to be replaced with TermuxConstants.EXTRA_TERMINATE_GRACE_PERIOD + intent.putExtra("com.termux.execute.stop.delay", gracePeriod); + + Context context = getApplicationContext(); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + // https://developer.android.com/about/versions/oreo/background.html + context.startForegroundService(intent); + } else { + context.startService(intent); + } + } + + private void scheduleNextExecution() { + CronScheduler.scheduleAlarmForJob(getApplicationContext(), jobId); + } +} From 735d13863dc942dddf5cda8e2a3956c7849e97d9 Mon Sep 17 00:00:00 2001 From: Christian Landvogt Date: Sun, 4 Feb 2024 21:50:46 +0100 Subject: [PATCH 02/12] Fix NPE if exception message is null --- app/src/main/java/com/termux/api/apis/CronAPI.java | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/termux/api/apis/CronAPI.java b/app/src/main/java/com/termux/api/apis/CronAPI.java index 876e2f9e7..992ce3b45 100644 --- a/app/src/main/java/com/termux/api/apis/CronAPI.java +++ b/app/src/main/java/com/termux/api/apis/CronAPI.java @@ -44,9 +44,10 @@ private static void handleAddJob(TermuxApiReceiver apiReceiver, Context context, CronScheduler.scheduleAlarmForJob(context, entry); ResultReturner.returnData(apiReceiver, intent, out -> out.println(entry.describe())); } catch (Exception e) { - Logger.logError(LOG_TAG, e.getMessage()); + String message = getExceptionMessage(e); + Logger.logError(LOG_TAG, message); Logger.logStackTrace(LOG_TAG, e); - ResultReturner.returnData(apiReceiver, intent, out -> out.println(e.getMessage())); + ResultReturner.returnData(apiReceiver, intent, out -> out.println(message)); } } @@ -90,4 +91,13 @@ private static void handleDelete(TermuxApiReceiver apiReceiver, Context context, out.println(String.format(Locale.getDefault(), "Cron job with id %d not found?", id))); } } + + private static String getExceptionMessage(Exception e) { + String message = e.getMessage(); + if (message != null) { + return message; + } else { + return String.format(Locale.getDefault(), "%s without message", e.getClass().getSimpleName()); + } + } } From 2ecff1af33ac79ea1dee1de6c9768275c4fc43c4 Mon Sep 17 00:00:00 2001 From: Christian Landvogt Date: Sun, 4 Feb 2024 21:50:59 +0100 Subject: [PATCH 03/12] Fix wrong or bad default values --- app/src/main/java/com/termux/api/cron/CronEntry.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/termux/api/cron/CronEntry.java b/app/src/main/java/com/termux/api/cron/CronEntry.java index 6656bdf9b..05d6778b4 100644 --- a/app/src/main/java/com/termux/api/cron/CronEntry.java +++ b/app/src/main/java/com/termux/api/cron/CronEntry.java @@ -167,10 +167,10 @@ public static CronEntry fromIntent(Intent intent, int newId) { ? NetworkType.NOT_REQUIRED : NetworkType.valueOf(networkTypeString.toUpperCase()); - long constraintTimeout = intent.getIntExtra("constraint_timeout", 60) * 1_000L; + long constraintTimeout = intent.getIntExtra("constraint_timeout", 300) * 1_000L; int gracePeriod = intent.getIntExtra("grace_period", 5_000); boolean continueOnConstraints = intent.getBooleanExtra("constraint_continue", false); - long maxRuntime = intent.getIntExtra("max_runtime", 60) * 1_000L; + long maxRuntime = intent.getIntExtra("max_runtime", 3600) * 1_000L; return new CronEntry(id, cronExpression, scriptPath, exact, networkType, batteryNotLow, charging, deviceIdle, storageNotLow, constraintTimeout, gracePeriod, continueOnConstraints, maxRuntime); From 577631b7d631cc00a2637dd6e41dd969144154f7 Mon Sep 17 00:00:00 2001 From: Christian Landvogt Date: Sun, 4 Feb 2024 21:51:22 +0100 Subject: [PATCH 04/12] Fix wrongly immutable list --- app/src/main/java/com/termux/api/cron/CronTab.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/termux/api/cron/CronTab.java b/app/src/main/java/com/termux/api/cron/CronTab.java index f77caa967..cd492e812 100644 --- a/app/src/main/java/com/termux/api/cron/CronTab.java +++ b/app/src/main/java/com/termux/api/cron/CronTab.java @@ -11,6 +11,7 @@ import java.lang.reflect.Type; import java.nio.charset.Charset; import java.nio.charset.StandardCharsets; +import java.util.ArrayList; import java.util.Collections; import java.util.List; import java.util.StringJoiner; @@ -102,7 +103,7 @@ private static List loadFromFile() { try { File file = new File(CRON_TAB_JSON_FILE); if (!file.exists()) { - return Collections.emptyList(); + return new ArrayList<>(); } String json = getTextFileContents(file); From bf6938c9fd916e8f814250869a5a81e52cc119fa Mon Sep 17 00:00:00 2001 From: Christian Landvogt Date: Sun, 4 Feb 2024 22:24:08 +0100 Subject: [PATCH 05/12] Fix TimeUnit in CronWorker maxRuntime --- app/src/main/java/com/termux/api/cron/CronWorker.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/com/termux/api/cron/CronWorker.java b/app/src/main/java/com/termux/api/cron/CronWorker.java index 72752ac87..81126bf0d 100644 --- a/app/src/main/java/com/termux/api/cron/CronWorker.java +++ b/app/src/main/java/com/termux/api/cron/CronWorker.java @@ -55,7 +55,7 @@ public Result doWork() { doneSignal.await(); hasFinished = true; } else { - hasFinished = doneSignal.await(maxRuntime, TimeUnit.SECONDS); + hasFinished = doneSignal.await(maxRuntime, TimeUnit.MILLISECONDS); } if (isStopped()) { From e584b5d383d60d5e81562b61ba42c6e6d0c82f22 Mon Sep 17 00:00:00 2001 From: Christian Landvogt Date: Mon, 5 Feb 2024 20:40:41 +0100 Subject: [PATCH 06/12] Delete unneeded permission as the target api level 28 --- app/src/main/AndroidManifest.xml | 1 - 1 file changed, 1 deletion(-) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index ea704716f..5e2778b29 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -28,7 +28,6 @@ - From 06d680f0a5cf881cad35dd13e3968cb060e7d1bf Mon Sep 17 00:00:00 2001 From: Christian Landvogt Date: Mon, 5 Feb 2024 20:41:53 +0100 Subject: [PATCH 07/12] Use setWindo instead of setAndAllowWhileIde --- app/src/main/java/com/termux/api/cron/CronScheduler.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/com/termux/api/cron/CronScheduler.java b/app/src/main/java/com/termux/api/cron/CronScheduler.java index 0e1acfb39..e3ac9e7dd 100644 --- a/app/src/main/java/com/termux/api/cron/CronScheduler.java +++ b/app/src/main/java/com/termux/api/cron/CronScheduler.java @@ -143,7 +143,7 @@ private static void scheduleAlarm(Context context, boolean exact, long triggerAt if (exact) { alarmManager.setExactAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, triggerAtMillis, pendingIntent); } else { - alarmManager.setAndAllowWhileIdle(AlarmManager.RTC_WAKEUP, triggerAtMillis, pendingIntent); + alarmManager.setWindow(AlarmManager.RTC_WAKEUP, triggerAtMillis, 10*60*1000L, pendingIntent); } Logger.logDebug(LOG_TAG, logMessage); } From 4ac2cc9447a95f86e7eb5164ea45f233d5629af8 Mon Sep 17 00:00:00 2001 From: Christian Landvogt Date: Thu, 8 Feb 2024 21:37:18 +0100 Subject: [PATCH 08/12] Explicitly set AppShell name in intent --- .../java/com/termux/api/cron/CronWorker.java | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/app/src/main/java/com/termux/api/cron/CronWorker.java b/app/src/main/java/com/termux/api/cron/CronWorker.java index 81126bf0d..a84ff02a1 100644 --- a/app/src/main/java/com/termux/api/cron/CronWorker.java +++ b/app/src/main/java/com/termux/api/cron/CronWorker.java @@ -14,6 +14,9 @@ import com.termux.shared.shell.command.ExecutionCommand; import com.termux.shared.termux.TermuxConstants; +import java.security.SecureRandom; +import java.util.Locale; +import java.util.Random; import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; @@ -29,6 +32,7 @@ public class CronWorker extends Worker { private long maxRuntime; private boolean continueOnConstraints; private int gracePeriod; + private String appShellName; public CronWorker(@NonNull Context appContext, @NonNull WorkerParameters workerParams) { super(appContext, workerParams); @@ -95,6 +99,8 @@ private void handleInputData() { maxRuntime = inputData.getLong(WORKER_INPUT_MAX_RUNTIME, 3600); continueOnConstraints = inputData.getBoolean(WORKER_INPUT_CONTINUE, false); gracePeriod = inputData.getInt(WORKER_INPUT_DELAY, 5000); + appShellName = createAppShellName(jobId, executableUri); + Logger.logDebug(LOG_TAG, getId() + " - " + appShellName); } @Override @@ -135,6 +141,7 @@ private void sendStartIntent() { intent.setClassName(TermuxConstants.TERMUX_PACKAGE_NAME, TermuxConstants.TERMUX_APP.TERMUX_SERVICE_NAME); intent.putExtra(TermuxConstants.TERMUX_APP.TERMUX_SERVICE.EXTRA_RUNNER, executionCommand.runner); intent.putExtra(TermuxConstants.TERMUX_APP.TERMUX_SERVICE.EXTRA_BACKGROUND, true); // Also pass in case user using termux-app version < 0.119.0 + intent.putExtra(TermuxConstants.TERMUX_APP.TERMUX_SERVICE.EXTRA_SHELL_NAME, appShellName); intent.putExtra(TermuxConstants.TERMUX_APP.TERMUX_SERVICE.EXTRA_PENDING_INTENT, pi); Context context = getApplicationContext(); @@ -151,6 +158,7 @@ private void sendKillIntent() { // needs to be replaced with TermuxConstants.ACTION_SERVICE_STOP Intent intent = new Intent("com.termux.service_execution_stop", executableUri); intent.setClassName(TermuxConstants.TERMUX_PACKAGE_NAME, TermuxConstants.TERMUX_APP.TERMUX_SERVICE_NAME); + intent.putExtra(TermuxConstants.TERMUX_APP.TERMUX_SERVICE.EXTRA_SHELL_NAME, appShellName); // needs to be replaced with TermuxConstants.EXTRA_TERMINATE_GRACE_PERIOD intent.putExtra("com.termux.execute.stop.delay", gracePeriod); @@ -166,4 +174,16 @@ private void sendKillIntent() { private void scheduleNextExecution() { CronScheduler.scheduleAlarmForJob(getApplicationContext(), jobId); } + + private static String createAppShellName(int jobId, Uri executableUri) { + char[] allowedCharsArray = ("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789").toCharArray(); + char[] randomId = new char[6]; + Random random = new SecureRandom(); + for (int i = 0; i < randomId.length; i++) { + randomId[i] = allowedCharsArray[random.nextInt(allowedCharsArray.length)]; + } + + return String.format(Locale.getDefault(), + "%s-%d-%s", executableUri.getLastPathSegment(), jobId, String.copyValueOf(randomId)); + } } From 866987fe32393d3d42669e8701d41fbb45a353ac Mon Sep 17 00:00:00 2001 From: Christian Landvogt Date: Thu, 8 Feb 2024 21:37:30 +0100 Subject: [PATCH 09/12] Fix ExistingWorkPolicy --- app/src/main/java/com/termux/api/cron/CronScheduler.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/com/termux/api/cron/CronScheduler.java b/app/src/main/java/com/termux/api/cron/CronScheduler.java index e3ac9e7dd..f466c6470 100644 --- a/app/src/main/java/com/termux/api/cron/CronScheduler.java +++ b/app/src/main/java/com/termux/api/cron/CronScheduler.java @@ -73,7 +73,7 @@ public static void enqueueWorkRequest(Context context, CronEntry entry) { WorkManager .getInstance(context) - .enqueueUniqueWork(getUniqueWorkName(entry.getId()), ExistingWorkPolicy.KEEP, builder.build()); + .enqueueUniqueWork(getUniqueWorkName(entry.getId()), ExistingWorkPolicy.REPLACE, builder.build()); Logger.logDebug(LOG_TAG, String.format(Locale.getDefault(), "CronWorker enqueued for job id %d", entry.getId())); From 8db3f64ec8ed2c3f767442b16ab96bba39f0c513 Mon Sep 17 00:00:00 2001 From: Christian Landvogt Date: Fri, 9 Feb 2024 20:08:41 +0100 Subject: [PATCH 10/12] Improve AppShell naming --- app/src/main/java/com/termux/api/cron/CronWorker.java | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/termux/api/cron/CronWorker.java b/app/src/main/java/com/termux/api/cron/CronWorker.java index a84ff02a1..2698051c0 100644 --- a/app/src/main/java/com/termux/api/cron/CronWorker.java +++ b/app/src/main/java/com/termux/api/cron/CronWorker.java @@ -10,12 +10,15 @@ import androidx.work.Data; import androidx.work.Worker; import androidx.work.WorkerParameters; +import com.termux.shared.file.FileUtils; import com.termux.shared.logger.Logger; +import com.termux.shared.net.uri.UriUtils; import com.termux.shared.shell.command.ExecutionCommand; import com.termux.shared.termux.TermuxConstants; import java.security.SecureRandom; import java.util.Locale; +import java.util.Optional; import java.util.Random; import java.util.concurrent.CountDownLatch; import java.util.concurrent.TimeUnit; @@ -183,7 +186,11 @@ private static String createAppShellName(int jobId, Uri executableUri) { randomId[i] = allowedCharsArray[random.nextInt(allowedCharsArray.length)]; } + String executable = Optional.ofNullable(FileUtils.getFileBasename(UriUtils + .getUriFilePathWithFragment(executableUri))) + .orElse("unknown-executable"); + return String.format(Locale.getDefault(), - "%s-%d-%s", executableUri.getLastPathSegment(), jobId, String.copyValueOf(randomId)); + "%s-%d-%s", executable, jobId, String.copyValueOf(randomId)); } } From ed5ab234d14604b6096b6807a1395f5afe1d242a Mon Sep 17 00:00:00 2001 From: Christian Landvogt Date: Fri, 9 Feb 2024 20:08:53 +0100 Subject: [PATCH 11/12] Adjust changed Termux constant --- app/src/main/java/com/termux/api/cron/CronWorker.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/com/termux/api/cron/CronWorker.java b/app/src/main/java/com/termux/api/cron/CronWorker.java index 2698051c0..ff14d2db7 100644 --- a/app/src/main/java/com/termux/api/cron/CronWorker.java +++ b/app/src/main/java/com/termux/api/cron/CronWorker.java @@ -163,7 +163,7 @@ private void sendKillIntent() { intent.setClassName(TermuxConstants.TERMUX_PACKAGE_NAME, TermuxConstants.TERMUX_APP.TERMUX_SERVICE_NAME); intent.putExtra(TermuxConstants.TERMUX_APP.TERMUX_SERVICE.EXTRA_SHELL_NAME, appShellName); // needs to be replaced with TermuxConstants.EXTRA_TERMINATE_GRACE_PERIOD - intent.putExtra("com.termux.execute.stop.delay", gracePeriod); + intent.putExtra("com.termux.execute.stop_delay", gracePeriod); Context context = getApplicationContext(); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { From 7f0543086bb2483db7206e80729944e0d8aa3a72 Mon Sep 17 00:00:00 2001 From: Christian Landvogt Date: Sun, 11 Feb 2024 20:47:56 +0100 Subject: [PATCH 12/12] Adjust to changed intent extra constant --- app/src/main/java/com/termux/api/cron/CronWorker.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/termux/api/cron/CronWorker.java b/app/src/main/java/com/termux/api/cron/CronWorker.java index ff14d2db7..7ef1a560c 100644 --- a/app/src/main/java/com/termux/api/cron/CronWorker.java +++ b/app/src/main/java/com/termux/api/cron/CronWorker.java @@ -162,8 +162,8 @@ private void sendKillIntent() { Intent intent = new Intent("com.termux.service_execution_stop", executableUri); intent.setClassName(TermuxConstants.TERMUX_PACKAGE_NAME, TermuxConstants.TERMUX_APP.TERMUX_SERVICE_NAME); intent.putExtra(TermuxConstants.TERMUX_APP.TERMUX_SERVICE.EXTRA_SHELL_NAME, appShellName); - // needs to be replaced with TermuxConstants.EXTRA_TERMINATE_GRACE_PERIOD - intent.putExtra("com.termux.execute.stop_delay", gracePeriod); + // needs to be replaced with TermuxConstants.EXTRA_SIGKILL_DELAY_ON_STOP + intent.putExtra("com.termux.execute.sigkill_delay_on_stop", gracePeriod); Context context = getApplicationContext(); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {