diff --git a/AndroidAnnotations/androidannotations-api/src/main/java/org/androidannotations/annotations/Background.java b/AndroidAnnotations/androidannotations-api/src/main/java/org/androidannotations/annotations/Background.java index 323b489223..1302ab7023 100644 --- a/AndroidAnnotations/androidannotations-api/src/main/java/org/androidannotations/annotations/Background.java +++ b/AndroidAnnotations/androidannotations-api/src/main/java/org/androidannotations/annotations/Background.java @@ -28,5 +28,26 @@ @Retention(RetentionPolicy.CLASS) @Target(ElementType.METHOD) public @interface Background { - long delay() default 0; + /** + * Identifier for task cancellation. + * + * To cancel all tasks having a specified background id: + * + *
+	 * boolean mayInterruptIfRunning = true;
+	 * BackgroundExecutor.cancelAll("my_background_id", mayInterruptIfRunning);
+	 * 
+ **/ + String id() default ""; + + /** Minimum delay, in milliseconds, before the background task is executed. */ + int delay() default 0; + + /** + * Serial execution group. + * + * All background tasks having the same serial will be executed + * sequentially. + **/ + String serial() default ""; } diff --git a/AndroidAnnotations/androidannotations/src/main/java/org/androidannotations/api/BackgroundExecutor.java b/AndroidAnnotations/androidannotations/src/main/java/org/androidannotations/api/BackgroundExecutor.java index 35b90a96aa..f757ffc656 100644 --- a/AndroidAnnotations/androidannotations/src/main/java/org/androidannotations/api/BackgroundExecutor.java +++ b/AndroidAnnotations/androidannotations/src/main/java/org/androidannotations/api/BackgroundExecutor.java @@ -15,29 +15,291 @@ */ package org.androidannotations.api; +import java.util.ArrayList; +import java.util.List; import java.util.concurrent.Executor; +import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; +import java.util.concurrent.Future; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.TimeUnit; +import android.util.Log; + public class BackgroundExecutor { - private static Executor executor = Executors.newCachedThreadPool(); - private static ScheduledExecutorService scheduledExecutor = Executors.newScheduledThreadPool(2 * Runtime.getRuntime().availableProcessors()); + private static final String TAG = "BackgroundExecutor"; + + private static Executor executor = Executors.newScheduledThreadPool(2 * Runtime.getRuntime().availableProcessors()); + + private static final List tasks = new ArrayList(); + + /** + * Execute a runnable after the given delay. + * + * @param runnable + * the task to execute + * @param delay + * the time from now to delay execution, in milliseconds + * @throws IllegalArgumentException + * if delay is strictly positive and the current + * executor does not support scheduling (if + * {@link #setExecutor(Executor)} has been called with such an + * executor) + * @return Future associated to the running task + */ + private static Future directExecute(Runnable runnable, int delay) { + Future future = null; + if (delay > 0) { + /* no serial, but a delay: schedule the task */ + if (!(executor instanceof ScheduledExecutorService)) { + throw new IllegalArgumentException("The executor set does not support scheduling"); + } + ScheduledExecutorService scheduledExecutorService = (ScheduledExecutorService) executor; + future = scheduledExecutorService.schedule(runnable, delay, TimeUnit.MILLISECONDS); + } else { + if (executor instanceof ExecutorService) { + ExecutorService executorService = (ExecutorService) executor; + future = executorService.submit(runnable); + } else { + /* non-cancellable task */ + executor.execute(runnable); + } + } + return future; + } + + /** + * Execute a task after (at least) its delay and after all + * tasks added with the same non-null serial (if any) have + * completed execution. + * + * @param task + * the task to execute + * @throws IllegalArgumentException + * if task.delay is strictly positive and the + * current executor does not support scheduling (if + * {@link #setExecutor(Executor)} has been called with such an + * executor) + */ + public static synchronized void execute(Task task) { + Future future = null; + if (task.serial == null || !hasSerialRunning(task.serial)) { + task.executionAsked = true; + future = directExecute(task, task.remainingDelay); + } + if (task.id != null || task.serial != null) { + /* keep task */ + task.future = future; + tasks.add(task); + } + } + /** + * Execute a task. + * + * @param runnable + * the task to execute + * @param id + * identifier used for task cancellation + * @param delay + * the time from now to delay execution, in milliseconds + * @param serial + * the serial queue (null or "" for no + * serial execution) + * @throws IllegalArgumentException + * if delay is strictly positive and the current + * executor does not support scheduling (if + * {@link #setExecutor(Executor)} has been called with such an + * executor) + */ + public static void execute(final Runnable runnable, String id, int delay, String serial) { + execute(new Task(id, delay, serial) { + @Override + public void execute() { + runnable.run(); + } + }); + } + + /** + * Execute a task after the given delay. + * + * @param runnable + * the task to execute + * @param delay + * the time from now to delay execution, in milliseconds + * @throws IllegalArgumentException + * if delay is strictly positive and the current + * executor does not support scheduling (if + * {@link #setExecutor(Executor)} has been called with such an + * executor) + */ + public static void execute(Runnable runnable, int delay) { + directExecute(runnable, delay); + } + + /** + * Execute a task. + * + * @param runnable + * the task to execute + */ public static void execute(Runnable runnable) { - executor.execute(runnable); + directExecute(runnable, 0); + } + + /** + * Execute a task after all tasks added with the same non-null + * serial (if any) have completed execution. + * + * Equivalent to {@link #execute(Runnable, String, int, String) + * execute(runnable, id, 0, serial)}. + * + * @param runnable + * the task to execute + * @param id + * identifier used for task cancellation + * @param serial + * the serial queue to use (null or "" + * for no serial execution) + */ + public static void execute(Runnable runnable, String id, String serial) { + execute(runnable, id, 0, serial); } + /** + * Change the executor. + * + * Note that if the given executor is not a {@link ScheduledExecutorService} + * then executing a task after a delay will not be supported anymore. If it + * is not even a {@link ExecutorService} then tasks will not be cancellable + * anymore. + * + * @param executor + * the new executor + */ public static void setExecutor(Executor executor) { BackgroundExecutor.executor = executor; } - public static void executeDelayed(Runnable runnable, long delay) { - scheduledExecutor.schedule(runnable, delay, TimeUnit.MILLISECONDS); + /** + * Cancel all tasks having the specified id. + * + * @param id + * the cancellation identifier + * @param mayInterruptIfRunning + * true if the thread executing this task should be + * interrupted; otherwise, in-progress tasks are allowed to + * complete + */ + public static synchronized void cancelAll(String id, boolean mayInterruptIfRunning) { + for (int i = tasks.size() - 1; i >= 0; i--) { + Task task = tasks.get(i); + if (id.equals(task.id)) { + tasks.remove(i); + if (task.future != null) { + task.future.cancel(mayInterruptIfRunning); + } else if (task.executionAsked) { + Log.w(TAG, "A task with id " + task.id + " cannot be cancelled (the executor set does not support it)"); + } + } + } } - public static void setScheduledExecutor(ScheduledExecutorService scheduledExecutor) { - BackgroundExecutor.scheduledExecutor = scheduledExecutor; + /** + * Indicates whether a task with the specified serial has been + * submitted to the executor. + * + * @param serial + * the serial queue + * @return true if such a task has been submitted, + * false otherwise + */ + private static boolean hasSerialRunning(String serial) { + for (Task task : tasks) { + if (task.executionAsked && serial.equals(task.serial)) { + return true; + } + } + return false; } + + /** + * Retrieve and remove the first task having the specified + * serial (if any). + * + * @param serial + * the serial queue + * @return task if found, null otherwise + */ + private static Task take(String serial) { + int len = tasks.size(); + for (int i = 0; i < len; i++) { + if (serial.equals(tasks.get(i).serial)) { + return tasks.remove(i); + } + } + return null; + } + + public static abstract class Task implements Runnable { + + private String id; + private int remainingDelay; + private long targetTimeMillis; /* since epoch */ + private String serial; + private boolean executionAsked; + private Future future; + + public Task(String id, int delay, String serial) { + if (!"".equals(id)) { + this.id = id; + } + if (delay > 0) { + remainingDelay = delay; + targetTimeMillis = System.currentTimeMillis() + delay; + } + if (!"".equals(serial)) { + this.serial = serial; + } + } + + @Override + public void run() { + try { + execute(); + } finally { + /* handle next tasks */ + postExecute(); + } + } + + public abstract void execute(); + + private void postExecute() { + if (id == null && serial == null) { + /* nothing to do */ + return; + } + synchronized (BackgroundExecutor.class) { + /* execution complete */ + tasks.remove(this); + + if (serial != null) { + Task next = take(serial); + if (next != null) { + if (next.remainingDelay != 0) { + /* the delay may not have elapsed yet */ + next.remainingDelay = Math.max(0, (int) (targetTimeMillis - System.currentTimeMillis())); + } + /* a task having the same serial was queued, execute it */ + BackgroundExecutor.execute(next); + } + } + } + } + + } + } diff --git a/AndroidAnnotations/androidannotations/src/main/java/org/androidannotations/helper/APTCodeModelHelper.java b/AndroidAnnotations/androidannotations/src/main/java/org/androidannotations/helper/APTCodeModelHelper.java index 1648c76447..4153fec49a 100644 --- a/AndroidAnnotations/androidannotations/src/main/java/org/androidannotations/helper/APTCodeModelHelper.java +++ b/AndroidAnnotations/androidannotations/src/main/java/org/androidannotations/helper/APTCodeModelHelper.java @@ -228,6 +228,20 @@ public String getIdStringFromIdFieldRef(JFieldRef idRef) { throw new IllegalStateException("Unable to extract target name from JFieldRef"); } + public JTryBlock surroundWithTryCatch(EBeanHolder holder, JBlock block, JBlock content, String exceptionMessage) { + Classes classes = holder.classes(); + JTryBlock tryBlock = block._try(); + tryBlock.body().add(content); + JCatchBlock catchBlock = tryBlock._catch(classes.RUNTIME_EXCEPTION); + JVar exceptionParam = catchBlock.param("e"); + JInvocation errorInvoke = classes.LOG.staticInvoke("e"); + errorInvoke.arg(holder.generatedClass.name()); + errorInvoke.arg(exceptionMessage); + errorInvoke.arg(exceptionParam); + catchBlock.body().add(errorInvoke); + return tryBlock; + } + public JDefinedClass createDelegatingAnonymousRunnableClass(EBeanHolder holder, JMethod delegatedMethod) { JCodeModel codeModel = holder.codeModel(); @@ -242,20 +256,9 @@ public JDefinedClass createDelegatingAnonymousRunnableClass(EBeanHolder holder, runMethod.annotate(Override.class); JBlock runMethodBody = runMethod.body(); - JTryBlock runTry = runMethodBody._try(); - runTry.body().add(previousMethodBody); - - JCatchBlock runCatch = runTry._catch(classes.RUNTIME_EXCEPTION); - JVar exceptionParam = runCatch.param("e"); - - JInvocation errorInvoke = classes.LOG.staticInvoke("e"); - - errorInvoke.arg(holder.generatedClass.name()); - errorInvoke.arg("A runtime exception was thrown while executing code in a runnable"); - errorInvoke.arg(exceptionParam); + surroundWithTryCatch(holder, runMethodBody, previousMethodBody, "A runtime exception was thrown while executing code in a runnable"); - runCatch.body().add(errorInvoke); return anonymousRunnableClass; } diff --git a/AndroidAnnotations/androidannotations/src/main/java/org/androidannotations/processing/BackgroundProcessor.java b/AndroidAnnotations/androidannotations/src/main/java/org/androidannotations/processing/BackgroundProcessor.java index 9db4750e47..896839e428 100644 --- a/AndroidAnnotations/androidannotations/src/main/java/org/androidannotations/processing/BackgroundProcessor.java +++ b/AndroidAnnotations/androidannotations/src/main/java/org/androidannotations/processing/BackgroundProcessor.java @@ -23,14 +23,17 @@ import org.androidannotations.annotations.Background; import org.androidannotations.api.BackgroundExecutor; +import org.androidannotations.api.BackgroundExecutor.Task; import org.androidannotations.helper.APTCodeModelHelper; +import com.sun.codemodel.JBlock; import com.sun.codemodel.JClass; import com.sun.codemodel.JClassAlreadyExistsException; import com.sun.codemodel.JCodeModel; import com.sun.codemodel.JDefinedClass; import com.sun.codemodel.JInvocation; import com.sun.codemodel.JMethod; +import com.sun.codemodel.JMod; public class BackgroundProcessor implements DecoratingElementProcessor { @@ -50,25 +53,25 @@ public void process(Element element, JCodeModel codeModel, EBeanHolder holder) t JMethod delegatingMethod = helper.overrideAnnotatedMethod(executableElement, holder); - JDefinedClass anonymousRunnableClass = helper.createDelegatingAnonymousRunnableClass(holder, delegatingMethod); + JBlock previousMethodBody = helper.removeBody(delegatingMethod); - { - // Execute Runnable - Background annotation = element.getAnnotation(Background.class); - long delay = annotation.delay(); + JDefinedClass anonymousTaskClass = codeModel.anonymousClass(Task.class); - JClass backgroundExecutorClass = holder.refClass(BackgroundExecutor.class); - JInvocation executeCall; + JMethod executeMethod = anonymousTaskClass.method(JMod.PUBLIC, codeModel.VOID, "execute"); + executeMethod.annotate(Override.class); - if (delay == 0) { - executeCall = backgroundExecutorClass.staticInvoke("execute").arg(_new(anonymousRunnableClass)); - } else { - executeCall = backgroundExecutorClass.staticInvoke("executeDelayed").arg(_new(anonymousRunnableClass)).arg(lit(delay)); - } + JBlock runMethodBody = executeMethod.body(); + helper.surroundWithTryCatch(holder, runMethodBody, previousMethodBody, "A runtime exception was thrown while executing code in a background task"); - delegatingMethod.body().add(executeCall); + Background annotation = element.getAnnotation(Background.class); + String id = annotation.id(); + int delay = annotation.delay(); + String serial = annotation.serial(); - } + JClass backgroundExecutorClass = holder.refClass(BackgroundExecutor.class); + JInvocation newTask = _new(anonymousTaskClass).arg(lit(id)).arg(lit(delay)).arg(lit(serial)); + JInvocation executeCall = backgroundExecutorClass.staticInvoke("execute").arg(newTask); + delegatingMethod.body().add(executeCall); } } diff --git a/AndroidAnnotations/functional-test-1-5-tests/src/test/java/org/androidannotations/test15/ThreadActivityTest.java b/AndroidAnnotations/functional-test-1-5-tests/src/test/java/org/androidannotations/test15/ThreadActivityTest.java index 23c53fe4ce..00fda24974 100644 --- a/AndroidAnnotations/functional-test-1-5-tests/src/test/java/org/androidannotations/test15/ThreadActivityTest.java +++ b/AndroidAnnotations/functional-test-1-5-tests/src/test/java/org/androidannotations/test15/ThreadActivityTest.java @@ -19,18 +19,27 @@ import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Random; import java.util.concurrent.Executor; +import java.util.concurrent.Executors; +import java.util.concurrent.Semaphore; +import java.util.concurrent.TimeUnit; +import org.androidannotations.api.BackgroundExecutor; +import org.junit.Assert; import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; import org.mockito.Mockito; -import org.androidannotations.api.BackgroundExecutor; - @RunWith(AndroidAnnotationsTestRunner.class) public class ThreadActivityTest { + private static final int MAX_WAITING_TIME = 3000; /* milliseconds */ + private ThreadActivity_ activity; @Before @@ -51,4 +60,207 @@ public void backgroundDelegatesToExecutor() { verify(executor).execute(Mockito.any()); } + /** + * Verify that non-serialized background tasks are not serialized (ensure that + * serial feature does not force all background tasks to be serialized). + * + * Start several requests which add an item to a list in background, without "@Background" + * serial attribute enabled. + * + * Once all tasks have completed execution, verify that the items in the list are not ordered + * (with very little false-negative probability). + */ + @Test + public void parallelBackgroundTasks() { + /* number of items to add to the list */ + final int NB_ADD = 20; + + /* set an executor with 4 threads */ + BackgroundExecutor.setExecutor(Executors.newFixedThreadPool(4)); + + List list = Collections.synchronizedList(new ArrayList()); + + /* sem.acquire() will be unlocked exactly after NB_ADD releases */ + Semaphore sem = new Semaphore(1 - NB_ADD); + + Random random = new Random(); + + /* execute NB_ADD requests to add an item to the list */ + for (int i = 0; i < NB_ADD; i++) { + /* + * wait a random delay (between 0 and 20 milliseconds) to increase the probability of + * wrong order + */ + int delay = random.nextInt(20); + activity.addBackground(list, i, delay, sem); + } + + try { + /* wait for all tasks to be completed */ + boolean acquired = sem.tryAcquire(MAX_WAITING_TIME, TimeUnit.MILLISECONDS); + Assert.assertTrue("Requested tasks should have completed execution", acquired); + + /* + * verify that list items are in the wrong order (the probability it is in the right is + * 1/(NB_ADD!), which is nearly 0) + */ + boolean rightOrder = true; + for (int i = 0; i < NB_ADD && rightOrder; i++) { + rightOrder &= i == list.get(i); + } + Assert.assertFalse("Items should not be in order", rightOrder); + } catch (InterruptedException e) { + Assert.assertFalse("Testing thread should never be interrupted", true); + } + } + + /** + * Verify that serialized background tasks are correctly serialized. + * + * Start several requests which add an item to a list in background, with "@Background" serial + * attribute enabled, so the requests must be executed sequentially. + * + * Once all tasks have completed execution, verify that the items in the list are ordered. + */ + @Test + public void serializedBackgroundTasks() { + /* number of items to add to the list */ + final int NB_ADD = 10; + + /* set an executor with 4 threads */ + BackgroundExecutor.setExecutor(Executors.newFixedThreadPool(4)); + + /* + * the calls are serialized, but not necessarily on the same thread, so we need to + * synchronize to avoid cache effects + */ + List list = Collections.synchronizedList(new ArrayList()); + + /* sem.acquire() will be unlocked exactly after NB_ADD releases */ + Semaphore sem = new Semaphore(1 - NB_ADD); + + Random random = new Random(); + + /* execute NB_ADD requests to add an item to the list */ + for (int i = 0; i < NB_ADD; i++) { + /* + * wait a random delay (between 0 and 20 milliseconds) to increase the probability of + * wrong order if buggy + */ + int delay = random.nextInt(20); + activity.addSerializedBackground(list, i, delay, sem); + } + + try { + /* wait for all tasks to be completed */ + boolean acquired = sem.tryAcquire(MAX_WAITING_TIME, TimeUnit.MILLISECONDS); + Assert.assertTrue("Requested tasks should have completed execution", acquired); + + for (int i = 0; i < NB_ADD; i++) { + Assert.assertEquals("Items must be in order", i, (int) list.get(i)); + } + } catch (InterruptedException e) { + Assert.assertFalse("Testing thread should never be interrupted", true); + } + } + + /** + * Verify that cancellable background tasks are correctly cancelled, and others are not. + * + * Start several requests which add an item to a list in background, half explicitly cancelled, + * half not cancelled. + * + * Once all tasks have completed execution, check if and only if the items from the uncancelled + * tasks are in the list. + */ + @Test + public void cancellableBackgroundTasks() { + /* number of items to add to the list */ + final int NB_ADD = 10; + + /* set an executor with 4 threads */ + BackgroundExecutor.setExecutor(Executors.newFixedThreadPool(4)); + + /* + * the calls are serialized, but not necessarily on the same thread, so we need to + * synchronize to avoid cache effects + */ + List list = Collections.synchronizedList(new ArrayList()); + + /* sem.acquire() will be unlocked exactly after NB_ADD releases */ + Semaphore sem = new Semaphore(1 - NB_ADD); + + /* execute 2*NB_ADD requests to add an item to the list, half being cancelled */ + for (int i = 0; i < NB_ADD; i++) { + activity.addBackground(list, i, 0, sem); + activity.addCancellableBackground(list, NB_ADD + i, 4000); + } + + /* cancel all tasks with id "to_cancel" */ + BackgroundExecutor.cancelAll("to_cancel", true); + + /* cancelled tasks won't have time to add any item */ + + try { + /* wait for all non cancelled tasks to be completed */ + boolean acquired = sem.tryAcquire(MAX_WAITING_TIME, TimeUnit.MILLISECONDS); + Assert.assertTrue("Requested tasks should have completed execution", acquired); + + Assert.assertEquals("Only uncancelled tasks must have added items", list.size(), NB_ADD); + + for (int i = 0; i < NB_ADD; i++) { + Assert.assertTrue("Items must be only from uncancelled tasks", i < NB_ADD); + } + } catch (InterruptedException e) { + Assert.assertFalse("Testing thread should never be interrupted", true); + } + } + + @Test + public void cancellableSerializedBackgroundTasks() { + /* number of items to add to the list */ + final int NB_ADD = 5; + + /* set an executor with 4 threads */ + BackgroundExecutor.setExecutor(Executors.newFixedThreadPool(4)); + + /* + * the calls are serialized, but not necessarily on the same thread, so we need to + * synchronize to avoid cache effects + */ + List list = Collections.synchronizedList(new ArrayList()); + + /* sem.acquire() will be unlocked exactly after NB_ADD releases */ + Semaphore sem = new Semaphore(1 - NB_ADD); + + /* execute 2*NB_ADD requests to add an item to the list, half being cancelled */ + for (int i = 0; i < NB_ADD; i++) { + activity.addSerializedBackground(list, i, 0, sem); + activity.addCancellableSerializedBackground(list, NB_ADD + i, 4000); + } + + /* cancel all tasks with id "to_cancel_serial" */ + BackgroundExecutor.cancelAll("to_cancel_serial", true); + + /* cancelled tasks won't have time to add any item */ + + try { + /* wait for all non cancelled tasks to be completed */ + boolean acquired = sem.tryAcquire(MAX_WAITING_TIME, TimeUnit.MILLISECONDS); + Assert.assertTrue("Requested tasks should have completed execution", acquired); + + /* cancel all tasks with id "to_cancel_2" */ + BackgroundExecutor.cancelAll("to_cancel_2", true); + + Assert.assertEquals("Only uncancelled tasks must have added items", list.size(), NB_ADD); + + for (int i = 0; i < NB_ADD; i++) { + Assert.assertTrue("Items must be only from uncancelled tasks", i < NB_ADD); + } + + } catch (InterruptedException e) { + Assert.assertFalse("Testing thread should never be interrupted", true); + } + } + } diff --git a/AndroidAnnotations/functional-test-1-5/src/main/java/org/androidannotations/test15/ThreadActivity.java b/AndroidAnnotations/functional-test-1-5/src/main/java/org/androidannotations/test15/ThreadActivity.java index 83410dd0de..58f79a63c9 100644 --- a/AndroidAnnotations/functional-test-1-5/src/main/java/org/androidannotations/test15/ThreadActivity.java +++ b/AndroidAnnotations/functional-test-1-5/src/main/java/org/androidannotations/test15/ThreadActivity.java @@ -17,7 +17,9 @@ import java.util.List; import java.util.Map; +import java.util.Random; import java.util.Set; +import java.util.concurrent.Semaphore; import org.androidannotations.annotations.Background; import org.androidannotations.annotations.EActivity; @@ -27,6 +29,7 @@ import org.androidannotations.test15.instancestate.MySerializableBean; import android.app.Activity; +import android.os.SystemClock; @EActivity public class ThreadActivity extends Activity { @@ -40,10 +43,42 @@ void emptyUiMethod() { void emptyBackgroundMethod() { } - + @Background(delay = 1000) void emptyDelayedBackgroundMethod() { - + + } + + private void add(List list, int i, int delay, Semaphore sem) { + try { + if (delay > 0) { + Thread.sleep(delay); + } + list.add(i); + if (sem != null) { + sem.release(); + } + } catch (InterruptedException e) {} + } + + @Background + void addBackground(List list, int i, int delay, Semaphore sem) { + add(list, i, delay, sem); + } + + @Background(serial = "test") + void addSerializedBackground(List list, int i, int delay, Semaphore sem) { + add(list, i, delay, sem); + } + + @Background(id = "to_cancel") + void addCancellableBackground(List list, int i, int interruptibleDelay) { + add(list, i, interruptibleDelay, null); + } + + @Background(id = "to_cancel_serial", serial = "test") + void addCancellableSerializedBackground(List list, int i, int delay) { + add(list, i, delay, null); } @UiThread