diff --git a/python-for-android/dists/kolibri/src/main/java/org/kivy/android/PythonWorker.java b/python-for-android/dists/kolibri/src/main/java/org/kivy/android/PythonWorker.java index 5908b944..0ef74a5b 100644 --- a/python-for-android/dists/kolibri/src/main/java/org/kivy/android/PythonWorker.java +++ b/python-for-android/dists/kolibri/src/main/java/org/kivy/android/PythonWorker.java @@ -1,5 +1,6 @@ package org.kivy.android; +import android.annotation.SuppressLint; import android.content.Context; import android.util.Log; @@ -7,11 +8,15 @@ import androidx.concurrent.futures.CallbackToFutureAdapter; import androidx.work.ForegroundInfo; import androidx.work.WorkerParameters; +import androidx.work.impl.utils.futures.SettableFuture; +import androidx.work.impl.utils.taskexecutor.WorkManagerTaskExecutor; import androidx.work.multiprocess.RemoteListenableWorker; import com.google.common.util.concurrent.ListenableFuture; -import java.util.concurrent.Executors; +import java.util.concurrent.Executor; +import java.util.concurrent.RunnableFuture; +import java.util.concurrent.ThreadPoolExecutor; abstract public class PythonWorker extends RemoteListenableWorker { private static final String TAG = "PythonWorker"; @@ -60,75 +65,89 @@ public boolean isLongRunning() { return getTags().contains(TAG_LONG_RUNNING); } + protected String getArgument() { + String dataArg = getInputData().getString(ARGUMENT_WORKER_ARGUMENT); + final String serviceArg; + if (dataArg != null) { + serviceArg = dataArg; + } else { + serviceArg = ""; + } + return serviceArg; + } + + protected Result doWork() { + String id = getId().toString(); + String arg = getArgument(); + + Log.d(TAG, id + " Running with python worker argument: " + arg); + + int res = nativeStart( + androidPrivate, androidArgument, + workerEntrypoint, pythonName, + pythonHome, pythonPath, + arg + ); + Log.d(TAG, id + " Finished remote python work: " + res); + + if (res == 0) { + return Result.success(); + } + + return Result.failure(); + } + + @SuppressLint("RestrictedApi") @NonNull @Override public ListenableFuture startRemoteWork() { - return CallbackToFutureAdapter.getFuture(completer -> { - String id = getId().toString(); - String dataArg = getInputData().getString(ARGUMENT_WORKER_ARGUMENT); - - final String serviceArg; - if (dataArg != null) { - Log.d(TAG, id + " Setting python worker argument to " + dataArg); - serviceArg = dataArg; - } else { - serviceArg = ""; - } - - if (isLongRunning()) { - Log.d(TAG, id + " Enabling foreground service for long running task"); - setForegroundAsync(getForegroundInfo()); - } - - // The python thread handling the work needs to be run in a - // separate thread so that future can be returned. Without - // it, any cancellation can't be processed. - final Thread pythonThread = new Thread(new Runnable() { - @Override - public void run() { - Log.d(TAG, id + " Running with python worker argument: " + serviceArg); - - try { - int res = nativeStart( - androidPrivate, androidArgument, - workerEntrypoint, pythonName, - pythonHome, pythonPath, - serviceArg - ); - Log.d(TAG, id + " Finished remote python work: " + res); - - if (res == 0) { - completer.set(Result.success()); - } else { - completer.set(Result.failure()); - } - } catch (Exception e) { - if (getRunAttemptCount() > MAX_WORKER_RETRIES) { - Log.e(TAG, id + " Exception in remote python work", e); - completer.setException(e); - } else { - Log.w(TAG, id + " Exception in remote python work, scheduling retry", e); - completer.set(Result.retry()); - } - } finally { - cleanup(); + SettableFuture future = SettableFuture.create(); + String id = getId().toString(); + + if (isLongRunning()) { + Log.d(TAG, id + " Enabling foreground service for long running task"); + setForegroundAsync(getForegroundInfo()); + } + + // See executor defined in configuration + ThreadPoolExecutor executor = (ThreadPoolExecutor) getBackgroundExecutor(); + // This is somewhat similar to what the plain `Worker` class does, except that we + // use `submit` instead of `execute` so we can propagate cancellation + // See https://android.googlesource.com/platform/frameworks/support/+/60ae0eec2a32396c22ad92502cde952c80d514a0/work/workmanager/src/main/java/androidx/work/Worker.java + RunnableFuture threadFuture = (RunnableFuture)executor.submit(new Runnable() { + @Override + public void run() { + try { + Result r = doWork(); + future.set(r); + } catch (Exception e) { + if (getRunAttemptCount() > MAX_WORKER_RETRIES) { + Log.e(TAG, id + " Exception in remote python work", e); + future.setException(e); + } else { + Log.w(TAG, id + " Exception in remote python work, scheduling retry", e); + future.set(Result.retry()); } + } finally { + cleanup(); } - }, "python_worker_thread"); + } + }); - completer.addCancellationListener(new Runnable() { - @Override - public void run() { - Log.i(TAG, id + " Interrupting remote work"); - pythonThread.interrupt(); + // If `RunnableFuture` was a `ListenableFuture` we could simply use `future.setFuture` to + // propagate the result and cancellation, but instead add listener to propagate + // cancellation to python thread, using the task executor which should invoke this in the + // main thread (where this was originally called from) + future.addListener(new Runnable() { + @Override + public void run() { + if (future.isCancelled()) { + Log.i(TAG, "Interrupting python thread"); + threadFuture.cancel(true); } - }, Executors.newSingleThreadExecutor()); - - Log.i(TAG, id + " Starting remote python work"); - pythonThread.start(); - - return TAG + " work thread"; - }); + } + }, getTaskExecutor().getMainThreadExecutor()); + return future; } // Native part diff --git a/python-for-android/dists/kolibri/src/main/java/org/learningequality/Kolibri/App.java b/python-for-android/dists/kolibri/src/main/java/org/learningequality/Kolibri/App.java index 5347cbaa..f14a7898 100644 --- a/python-for-android/dists/kolibri/src/main/java/org/learningequality/Kolibri/App.java +++ b/python-for-android/dists/kolibri/src/main/java/org/learningequality/Kolibri/App.java @@ -9,10 +9,15 @@ import androidx.core.app.NotificationChannelCompat; import androidx.work.Configuration; +import org.learningequality.NotificationRef; + +import java.util.concurrent.Executors; + public class App extends Application implements Configuration.Provider { @Override public void onCreate() { super.onCreate(); + NotificationRef.initialize(this); createNotificationChannels(); } @@ -25,6 +30,7 @@ public Configuration getWorkManagerConfiguration() { return new Configuration.Builder() .setDefaultProcessName(processName) .setMinimumLoggingLevel(android.util.Log.DEBUG) + .setExecutor(Executors.newFixedThreadPool(20)) .build(); } diff --git a/python-for-android/dists/kolibri/src/main/java/org/learningequality/Kolibri/TaskworkerWorker.java b/python-for-android/dists/kolibri/src/main/java/org/learningequality/Kolibri/TaskworkerWorker.java index 372c84c3..7e8e21ce 100644 --- a/python-for-android/dists/kolibri/src/main/java/org/learningequality/Kolibri/TaskworkerWorker.java +++ b/python-for-android/dists/kolibri/src/main/java/org/learningequality/Kolibri/TaskworkerWorker.java @@ -52,6 +52,7 @@ public ForegroundInfo getForegroundInfo() { ref = TaskworkerWorkerService.mService.getNotificationRef(); } else { ref = getNotificationRef(); + Log.w(TAG, "No service found, using worker notification for foreground"); } NotificationBuilder builder = new NotificationBuilder(getApplicationContext(), ref); diff --git a/python-for-android/dists/kolibri/src/main/java/org/learningequality/Kolibri/TaskworkerWorkerService.java b/python-for-android/dists/kolibri/src/main/java/org/learningequality/Kolibri/TaskworkerWorkerService.java index b367310f..cd649f43 100644 --- a/python-for-android/dists/kolibri/src/main/java/org/learningequality/Kolibri/TaskworkerWorkerService.java +++ b/python-for-android/dists/kolibri/src/main/java/org/learningequality/Kolibri/TaskworkerWorkerService.java @@ -19,14 +19,17 @@ public class TaskworkerWorkerService extends RemoteWorkerService implements Noti @Override public void onCreate() { + mService = this; Context context = getApplicationContext(); - Log.v(TAG, "Initializing WorkManager"); - WorkManager.getInstance(getApplicationContext()); - super.onCreate(); + Log.v(TAG, "Initializing task worker service"); PythonUtil.loadLibraries( new File(context.getApplicationInfo().nativeLibraryDir) ); - mService = this; + // Initialize the work manager + WorkManager.getInstance(getApplicationContext()); + super.onCreate(); + // We could potentially remove this and leave the notification up to long-running workers + // bound to the service sendNotification(); } diff --git a/python-for-android/dists/kolibri/src/main/java/org/learningequality/NotificationBuilder.java b/python-for-android/dists/kolibri/src/main/java/org/learningequality/NotificationBuilder.java index 4e04648d..fa2451ad 100644 --- a/python-for-android/dists/kolibri/src/main/java/org/learningequality/NotificationBuilder.java +++ b/python-for-android/dists/kolibri/src/main/java/org/learningequality/NotificationBuilder.java @@ -1,6 +1,9 @@ package org.learningequality; +import android.app.PendingIntent; import android.content.Context; +import android.content.Intent; +import android.provider.Settings; import androidx.core.app.NotificationCompat; @@ -17,21 +20,36 @@ public NotificationBuilder(Context context, String channelId) { // Default title String notificationTitle = context.getApplicationContext().getString(R.string.app_name); setContentTitle(notificationTitle); - } - - public NotificationBuilder(Context context, int channelRef) { - this(context, NotificationRef.getChannelId(context, channelRef)); // defaults for service notification channel - if (channelRef == NotificationRef.REF_CHANNEL_SERVICE) { + if (channelId.equals(NotificationRef.ID_CHANNEL_SERVICE)) { setOngoing(true); setCategory(NotificationCompat.CATEGORY_SERVICE); - setContentTitle(context.getString(R.string.notification_service_channel_content)); - } else if (channelRef == NotificationRef.REF_CHANNEL_DEFAULT) { + setContentText(context.getString(R.string.notification_service_channel_content)); + setTicker(context.getString(R.string.notification_service_channel_ticker)); + + // Add settings button to notification for quick access to the minimize setting for this + // foreground notification channel + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) { + Intent intent = new Intent(Settings.ACTION_CHANNEL_NOTIFICATION_SETTINGS); + intent.putExtra(Settings.EXTRA_APP_PACKAGE, context.getPackageName()); + intent.putExtra(Settings.EXTRA_CHANNEL_ID, channelId); + addAction(new NotificationCompat.Action.Builder( + R.drawable.baseline_notifications_paused_24, + context.getString(R.string.notification_service_channel_action), + PendingIntent.getActivity(context, 0, intent, PendingIntent.FLAG_IMMUTABLE) + ).build()); + } + } else if (channelId.equals(NotificationRef.ID_CHANNEL_DEFAULT)) { setCategory(NotificationCompat.CATEGORY_PROGRESS); + setTicker(context.getString(R.string.notification_default_channel_ticker)); } } + public NotificationBuilder(Context context, int channelRef) { + this(context, NotificationRef.getChannelId(context, channelRef)); + } + public NotificationBuilder(Context context, NotificationRef ref) { this(context, ref.getChannelRef()); } diff --git a/python-for-android/dists/kolibri/src/main/java/org/learningequality/NotificationRef.java b/python-for-android/dists/kolibri/src/main/java/org/learningequality/NotificationRef.java index a0013057..81329b74 100644 --- a/python-for-android/dists/kolibri/src/main/java/org/learningequality/NotificationRef.java +++ b/python-for-android/dists/kolibri/src/main/java/org/learningequality/NotificationRef.java @@ -8,6 +8,9 @@ public final class NotificationRef { public static final int ID_DEFAULT = 1; public static final int REF_CHANNEL_SERVICE = 1; public static final int REF_CHANNEL_DEFAULT = 2; + public static String ID_CHANNEL_DEFAULT = null; + public static String ID_CHANNEL_SERVICE = null; + private static boolean initialized = false; private final int channelRef; private final String tag; private final int id; @@ -38,12 +41,22 @@ public String getTag() { return tag; } + public static void initialize(Context context) { + if (initialized) { + return; + } + ID_CHANNEL_DEFAULT = context.getString(R.string.notification_default_channel_id); + ID_CHANNEL_SERVICE = context.getString(R.string.notification_service_channel_id); + initialized = true; + } + public static String getChannelId(Context context, int channelRef) { + initialize(context); switch (channelRef) { case REF_CHANNEL_SERVICE: - return context.getString(R.string.notification_service_channel_id); + return ID_CHANNEL_SERVICE; case REF_CHANNEL_DEFAULT: - return context.getString(R.string.notification_default_channel_id); + return ID_CHANNEL_DEFAULT; default: return null; } diff --git a/python-for-android/dists/kolibri/src/main/res/drawable/baseline_notifications_paused_24.xml b/python-for-android/dists/kolibri/src/main/res/drawable/baseline_notifications_paused_24.xml new file mode 100644 index 00000000..3e64e90d --- /dev/null +++ b/python-for-android/dists/kolibri/src/main/res/drawable/baseline_notifications_paused_24.xml @@ -0,0 +1,5 @@ + + + diff --git a/python-for-android/dists/kolibri/src/main/res/values/strings.xml b/python-for-android/dists/kolibri/src/main/res/values/strings.xml index 9535fd74..98825068 100644 --- a/python-for-android/dists/kolibri/src/main/res/values/strings.xml +++ b/python-for-android/dists/kolibri/src/main/res/values/strings.xml @@ -7,6 +7,9 @@ background_notifications Background Notifications Background tasks + A persistent notification of Kolibri\'s background processing + Manage task_notifications Task Notifications + A progress notification of Kolibri\'s background processing