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 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", 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)); + } +}