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..5e2778b29 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -141,7 +141,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..992ce3b45
--- /dev/null
+++ b/app/src/main/java/com/termux/api/apis/CronAPI.java
@@ -0,0 +1,103 @@
+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) {
+ String message = getExceptionMessage(e);
+ Logger.logError(LOG_TAG, message);
+ Logger.logStackTrace(LOG_TAG, e);
+ ResultReturner.returnData(apiReceiver, intent, out -> out.println(message));
+ }
+ }
+
+ 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)));
+ }
+ }
+
+ 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());
+ }
+ }
+}
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..05d6778b4
--- /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 extends BroadcastReceiver> 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 extends BroadcastReceiver> 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", 300) * 1_000L;
+ int gracePeriod = intent.getIntExtra("grace_period", 5_000);
+ boolean continueOnConstraints = intent.getBooleanExtra("constraint_continue", false);
+ 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);
+ }
+
+ 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..f466c6470
--- /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.REPLACE, 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.setWindow(AlarmManager.RTC_WAKEUP, triggerAtMillis, 10*60*1000L, 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..cd492e812
--- /dev/null
+++ b/app/src/main/java/com/termux/api/cron/CronTab.java
@@ -0,0 +1,158 @@
+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.ArrayList;
+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 new ArrayList<>();
+ }
+
+ 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..7ef1a560c
--- /dev/null
+++ b/app/src/main/java/com/termux/api/cron/CronWorker.java
@@ -0,0 +1,196 @@
+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.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;
+
+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;
+ private String appShellName;
+
+ 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.MILLISECONDS);
+ }
+
+ 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);
+ appShellName = createAppShellName(jobId, executableUri);
+ Logger.logDebug(LOG_TAG, getId() + " - " + appShellName);
+ }
+
+ @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_SHELL_NAME, appShellName);
+ 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);
+ intent.putExtra(TermuxConstants.TERMUX_APP.TERMUX_SERVICE.EXTRA_SHELL_NAME, appShellName);
+ // 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) {
+ // https://developer.android.com/about/versions/oreo/background.html
+ context.startForegroundService(intent);
+ } else {
+ context.startService(intent);
+ }
+ }
+
+ 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)];
+ }
+
+ String executable = Optional.ofNullable(FileUtils.getFileBasename(UriUtils
+ .getUriFilePathWithFragment(executableUri)))
+ .orElse("unknown-executable");
+
+ return String.format(Locale.getDefault(),
+ "%s-%d-%s", executable, jobId, String.copyValueOf(randomId));
+ }
+}