diff --git a/python-for-android/dists/kolibri/src/main/java/org/kivy/android/PythonContext.java b/python-for-android/dists/kolibri/src/main/java/org/kivy/android/PythonContext.java index 6ed4cb2f..5ff7f9a2 100644 --- a/python-for-android/dists/kolibri/src/main/java/org/kivy/android/PythonContext.java +++ b/python-for-android/dists/kolibri/src/main/java/org/kivy/android/PythonContext.java @@ -1,14 +1,64 @@ package org.kivy.android; import android.content.Context; +import android.content.pm.PackageInfo; +import android.content.pm.PackageManager; +import android.net.ConnectivityManager; +import android.net.Network; +import android.net.NetworkCapabilities; +import android.net.NetworkRequest; +import android.os.Build; +import android.provider.Settings; + +import java.util.Locale; +import java.util.concurrent.atomic.AtomicBoolean; public class PythonContext { + public static final String PACKAGE = "org.learningequality.Kolibri"; public static PythonContext mInstance; private final Context context; + private final ConnectivityManager connectivityManager; + private final ConnectivityManager.NetworkCallback networkCallback; + private final AtomicBoolean isMetered = new AtomicBoolean(false); + private final String externalFilesDir; + private final String versionName; + private final String certificateInfo; + private final String nodeId; private PythonContext(Context context) { this.context = context; + this.connectivityManager = (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE); + + this.networkCallback = new ConnectivityManager.NetworkCallback() { + @Override + public void onAvailable(Network network) { + super.onAvailable(network); + isMetered.set(connectivityManager.isActiveNetworkMetered()); + } + + @Override + public void onLost(Network network) { + super.onLost(network); + isMetered.set(false); + } + }; + + NetworkRequest networkRequest = new NetworkRequest.Builder() + .addCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) + .build(); + + this.connectivityManager.registerNetworkCallback(networkRequest, this.networkCallback); + + this.externalFilesDir = context.getExternalFilesDir(null).toString(); + + PackageInfo packageInfo = getPackageInfo(); + this.versionName = packageInfo.versionName; + + PackageInfo certificateInfo = getPackageInfo(PackageManager.GET_SIGNATURES); + this.certificateInfo = certificateInfo.signatures[0].toCharsString(); + + this.nodeId = Settings.Secure.getString(context.getContentResolver(), Settings.Secure.ANDROID_ID); } public static PythonContext getInstance(Context context) { @@ -24,10 +74,74 @@ public static PythonContext getInstance(Context context) { return PythonContext.mInstance; } + // TODO: remove this, and don't store context on the class public static Context get() { if (PythonContext.mInstance == null) { return null; } return PythonContext.mInstance.context; } + + public static String getLocale() { + return Locale.getDefault().toLanguageTag(); + } + + public static Boolean isActiveNetworkMetered() { + if (PythonContext.mInstance == null) { + return null; + } + return PythonContext.mInstance.isMetered.get(); + } + + public static String getExternalFilesDir() { + if (PythonContext.mInstance == null) { + return null; + } + return PythonContext.mInstance.externalFilesDir; + } + + public static String getVersionName() { + if (PythonContext.mInstance == null) { + return null; + } + return PythonContext.mInstance.versionName; + } + + public static String getCertificateInfo() { + if (PythonContext.mInstance == null) { + return null; + } + return PythonContext.mInstance.certificateInfo; + } + + public static String getNodeId() { + if (PythonContext.mInstance == null) { + return null; + } + return PythonContext.mInstance.nodeId; + } + + public static void destroy() { + if (PythonContext.mInstance != null) { + PythonContext.mInstance.connectivityManager.unregisterNetworkCallback(PythonContext.mInstance.networkCallback); + PythonContext.mInstance = null; + } + } + + protected PackageInfo getPackageInfo() { + return getPackageInfo(0); + } + + protected PackageInfo getPackageInfo(int flags) { + PackageManager packageManager = context.getPackageManager(); + try { + if (android.os.Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + return packageManager.getPackageInfo(PACKAGE, PackageManager.PackageInfoFlags.of(flags)); + } else { + return packageManager.getPackageInfo(PACKAGE, flags); + } + } catch (PackageManager.NameNotFoundException e) { + throw new RuntimeException("Kolibri is not installed"); + } + } } 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 9ea02666..79fa36ca 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 @@ -9,11 +9,15 @@ import java.io.File; /** - * Ideally this would be called `PythonWorkerImpl` but the name is used in the native code. + * Worker implementation that executes Python code. + * + * Ideally this would be called `PythonWorkerImpl` but the name is used in the native + * python-for-android code. */ public class PythonWorker { private static final String TAG = "PythonWorkerImpl"; // Python environment variables + private final Context context; private final String pythonName; private final String workerEntrypoint; private final String androidPrivate; @@ -22,7 +26,7 @@ public class PythonWorker { private final String pythonPath; public PythonWorker(@NonNull Context context, String pythonName, String workerEntrypoint) { - PythonLoader.doLoad(context); + this.context = context; this.pythonName = pythonName; this.workerEntrypoint = workerEntrypoint; @@ -33,6 +37,15 @@ public PythonWorker(@NonNull Context context, String pythonName, String workerEn pythonPath = appRoot + ":" + appRoot + "/lib"; } + /** + * Prepare the Python environment. + * + * This should be called before any calls to `execute`. + */ + public void prepare() { + PythonLoader.doLoad(context); + } + // Native part public static native int nativeStart( String androidPrivate, String androidArgument, diff --git a/python-for-android/dists/kolibri/src/main/java/org/learningequality/ContextUtil.java b/python-for-android/dists/kolibri/src/main/java/org/learningequality/ContextUtil.java index 037ff58c..d81985b7 100644 --- a/python-for-android/dists/kolibri/src/main/java/org/learningequality/ContextUtil.java +++ b/python-for-android/dists/kolibri/src/main/java/org/learningequality/ContextUtil.java @@ -1,6 +1,8 @@ package org.learningequality; +import android.app.ActivityManager; import android.content.Context; +import android.os.Process; import org.kivy.android.PythonActivity; import org.learningequality.Kolibri.WorkerService; @@ -23,4 +25,21 @@ public static boolean isActivityContext() { public static boolean isServiceContext() { return WorkerService.mService != null; } + + /** + * Get the name of the current process. + * + * @param context - the context to use + * @return the name of the current process as a string, or an empty string if not found + */ + public static String getCurrentProcessName(Context context) { + int pid = Process.myPid(); + ActivityManager manager = (ActivityManager) context.getSystemService(Context.ACTIVITY_SERVICE); + for (ActivityManager.RunningAppProcessInfo processInfo : manager.getRunningAppProcesses()) { + if (processInfo.pid == pid) { + return processInfo.processName; + } + } + return ""; + } } 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 266cb93c..97f21a7e 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 @@ -12,6 +12,7 @@ import androidx.work.Configuration; import org.kivy.android.PythonContext; +import org.learningequality.ContextUtil; import org.learningequality.notification.NotificationRef; import java.util.concurrent.Executors; @@ -27,8 +28,12 @@ public void onCreate() { // Initialize Python context PythonContext.getInstance(this); createNotificationChannels(); - // Register activity lifecycle callbacks - registerActivityLifecycleCallbacks(new KolibriActivityLifecycleCallbacks()); + + String currentProcessName = ContextUtil.getCurrentProcessName(this); + if (currentProcessName.endsWith(getString(R.string.task_worker_process))) { + // Register activity lifecycle callbacks + registerActivityLifecycleCallbacks(new KolibriActivityLifecycleCallbacks()); + } WorkController.getInstance(this).wake(); } diff --git a/python-for-android/dists/kolibri/src/main/java/org/learningequality/Kolibri/BackgroundWorker.java b/python-for-android/dists/kolibri/src/main/java/org/learningequality/Kolibri/BackgroundWorker.java index ffc2897d..38200222 100644 --- a/python-for-android/dists/kolibri/src/main/java/org/learningequality/Kolibri/BackgroundWorker.java +++ b/python-for-android/dists/kolibri/src/main/java/org/learningequality/Kolibri/BackgroundWorker.java @@ -5,6 +5,7 @@ import android.util.Log; import androidx.annotation.NonNull; +import androidx.work.ListenableWorker; import androidx.work.WorkerParameters; import org.learningequality.task.Worker; @@ -23,6 +24,10 @@ public BackgroundWorker( ) { super(context, workerParams); workerImpl = new PythonWorker(context, "TaskWorker", "taskworker.py"); + + // Ideally we wouldn't call this in the constructor, but we can't override `startWork` to + // call it just before `doWork` is called. + workerImpl.prepare(); } /** diff --git a/python-for-android/dists/kolibri/src/main/java/org/learningequality/Kolibri/ForegroundWorker.java b/python-for-android/dists/kolibri/src/main/java/org/learningequality/Kolibri/ForegroundWorker.java index a7c2c3f5..a5a2829e 100644 --- a/python-for-android/dists/kolibri/src/main/java/org/learningequality/Kolibri/ForegroundWorker.java +++ b/python-for-android/dists/kolibri/src/main/java/org/learningequality/Kolibri/ForegroundWorker.java @@ -41,6 +41,8 @@ public ListenableFuture startRemoteWork() { final String id = getId().toString(); final String arg = getArgument(); + workerImpl.prepare(); + // See executor defined in configuration final ThreadPoolExecutor executor = (ThreadPoolExecutor) getBackgroundExecutor(); // This is somewhat similar to what the plain `Worker` class does, except that we diff --git a/python-for-android/dists/kolibri/src/main/java/org/learningequality/Kolibri/task/Reconciler.java b/python-for-android/dists/kolibri/src/main/java/org/learningequality/Kolibri/task/Reconciler.java index 6b826762..53733b22 100644 --- a/python-for-android/dists/kolibri/src/main/java/org/learningequality/Kolibri/task/Reconciler.java +++ b/python-for-android/dists/kolibri/src/main/java/org/learningequality/Kolibri/task/Reconciler.java @@ -7,70 +7,33 @@ import androidx.work.OneTimeWorkRequest; import androidx.work.multiprocess.RemoteWorkManager; +import org.learningequality.ContextUtil; import org.learningequality.FuturesUtil; +import org.learningequality.Kolibri.R; import org.learningequality.Kolibri.sqlite.JobStorage; import org.learningequality.sqlite.query.UpdateQuery; -import java.io.File; -import java.io.IOException; -import java.io.RandomAccessFile; -import java.nio.channels.FileChannel; -import java.nio.channels.FileLock; import java.util.ArrayList; import java.util.List; import java.util.UUID; import java.util.concurrent.Executor; +import java.util.concurrent.atomic.AtomicBoolean; import java9.util.concurrent.CompletableFuture; public class Reconciler implements AutoCloseable { public static final String TAG = "Kolibri.TaskReconciler"; - public static final String LOCK_FILE = "kolibri_reconciler.lock"; + private static final AtomicBoolean lock = new AtomicBoolean(false); private final RemoteWorkManager workManager; - private final LockChannel lockChannel; private final JobStorage db; private final Executor executor; - private FileLock lock; - protected static class LockChannel { - private static LockChannel mInstance; - private final FileChannel channel; - - public LockChannel(File lockFile) { - try { - channel = new RandomAccessFile(lockFile, "rw").getChannel(); - } catch (Exception e) { - throw new RuntimeException(e); - } - } - - public FileLock tryLock() { - try { - return this.channel.tryLock(); - } catch (IOException e) { - Log.e(TAG, "Failed to acquire lock", e); - return null; - } - } - - public static LockChannel getInstance(Context context) { - if (mInstance == null) { - File lockFile = new File(context.getFilesDir(), LOCK_FILE); - mInstance = new LockChannel(lockFile); - } - return mInstance; - } - } - - - public Reconciler(RemoteWorkManager workManager, JobStorage db, LockChannel lockChannel, Executor executor) { + public Reconciler(RemoteWorkManager workManager, JobStorage db, Executor executor) { this.workManager = workManager; this.db = db; - this.lockChannel = lockChannel; this.executor = executor; - } /** @@ -78,29 +41,34 @@ public Reconciler(RemoteWorkManager workManager, JobStorage db, LockChannel lock * @param context The context to use * @return A new Reconciler instance */ - public static Reconciler from(Context context, JobStorage db, Executor executor) { + public static Reconciler from(Context context, JobStorage db, Executor executor) throws RuntimeException { + // Ensure that we're in the task worker process + String expectedProcessSuffix = context.getString(R.string.task_worker_process); + String currentProcessName = ContextUtil.getCurrentProcessName(context); + if (!currentProcessName.endsWith(expectedProcessSuffix)) { + throw new RuntimeException("Refusing to create Reconciler in process " + currentProcessName); + } RemoteWorkManager workManager = RemoteWorkManager.getInstance(context); - return new Reconciler(workManager, db, LockChannel.getInstance(context), executor); + return new Reconciler(workManager, db, executor); } /** - * Attempt to acquire an exclusive lock on the lock file, which will prevent multiple - * Reconciler instances from running at the same time, including in different processes. - * Also starts a transaction on the database. + * Synchronizes on the atomic boolean as a locking mechanism, which will prevent multiple + * Reconciler instances from running at the same time. Reconciler.from already prevents this + * from running in multiple processes. * @return True if the lock was acquired, false otherwise */ public boolean begin() { // First get a lock on the lock file Log.d(TAG, "Acquiring lock"); - lock = lockChannel.tryLock(); - if (lock == null) { - Log.d(TAG, "Failed to acquire lock"); - return false; + synchronized (lock) { + if (lock.get()) { + Log.d(TAG, "Lock already acquired"); + return false; + } + lock.set(true); } - // Then start a transaction - Log.d(TAG, "Beginning transaction"); -// db.begin(); return true; } @@ -108,14 +76,9 @@ public boolean begin() { * Commit the database transaction and release the lock */ public void end() { - Log.d(TAG, "Committing transaction"); -// db.commit(); - - try { - Log.d(TAG, "Releasing lock"); - if (lock != null) lock.release(); - } catch (Exception e) { - Log.e(TAG, "Failed to close and release lock", e); + Log.d(TAG, "Releasing lock"); + synchronized (lock) { + lock.set(false); } } diff --git a/python-for-android/dists/kolibri/src/main/java/org/learningequality/Task.java b/python-for-android/dists/kolibri/src/main/java/org/learningequality/Task.java index 7ed83bf3..00b219bc 100644 --- a/python-for-android/dists/kolibri/src/main/java/org/learningequality/Task.java +++ b/python-for-android/dists/kolibri/src/main/java/org/learningequality/Task.java @@ -88,7 +88,14 @@ public static CompletableFuture reconcile(Context context, Executor exe final AtomicBoolean didReconcile = new AtomicBoolean(false); final JobStorage db = JobStorage.readwrite(context); - final Reconciler reconciler = Reconciler.from(context, db, executor); + + final Reconciler reconciler; + try { + reconciler = Reconciler.from(context, db, executor); + } catch (Exception e) { + Log.e(TAG, "Failed to create reconciler", e); + return CompletableFuture.completedFuture(false); + } if (db == null) { Log.e(Sentinel.TAG, "Failed to open job storage database"); diff --git a/src/android_utils.py b/src/android_utils.py index 94353949..2886c776 100644 --- a/src/android_utils.py +++ b/src/android_utils.py @@ -1,4 +1,3 @@ -import json import os import re from functools import cache @@ -10,41 +9,19 @@ from jnius import cast -def is_service_context(): - return "PYTHON_SERVICE_ARGUMENT" in os.environ - - -def is_taskworker_context(): - return "PYTHON_WORKER_ARGUMENT" in os.environ - - def get_timezone_name(): Timezone = autoclass("java.util.TimeZone") return Timezone.getDefault().getDisplayName() -def start_service(service_name, service_args=None): - PythonActivity = autoclass("org.kivy.android.PythonActivity") - service_args = service_args or {} - service = autoclass( - "org.learningequality.Kolibri.Service{}".format(service_name.title()) - ) - service.start(PythonActivity.mActivity, json.dumps(dict(service_args))) - - -def get_service_args(): - assert ( - is_service_context() - ), "Cannot get service args, as we are not in a service context." - return json.loads(os.environ.get("PYTHON_SERVICE_ARGUMENT") or "{}") - - -def get_package_info(package_name="org.learningequality.Kolibri", flags=0): - return get_context().getPackageManager().getPackageInfo(package_name, flags) +def get_version_name(): + PythonContext = autoclass("org.kivy.android.PythonContext") + return PythonContext.getVersionName() -def get_version_name(): - return get_package_info().versionName +def get_node_id(): + PythonContext = autoclass("org.kivy.android.PythonContext") + return PythonContext.getNodeId() @cache @@ -55,7 +32,8 @@ def get_context(): @cache def get_external_files_dir(): - return get_context().getExternalFilesDir(None).toString() + PythonContext = autoclass("org.kivy.android.PythonContext") + return PythonContext.getExternalFilesDir() # TODO: check for storage availability, allow user to chose sd card or internal @@ -145,61 +123,11 @@ def share_by_intent(path=None, filename=None, message=None, app=None, mimetype=N get_context().startActivity(sendIntent) -def make_service_foreground(title, message): - service = autoclass("org.kivy.android.PythonService").mService - Drawable = autoclass("{}.R$drawable".format(service.getPackageName())) - app_context = service.getApplication().getApplicationContext() - - ANDROID_VERSION = autoclass("android.os.Build$VERSION") - SDK_INT = ANDROID_VERSION.SDK_INT - AndroidString = autoclass("java.lang.String") - Context = autoclass("android.content.Context") - Intent = autoclass("android.content.Intent") - NotificationBuilder = autoclass("android.app.Notification$Builder") - NotificationManager = autoclass("android.app.NotificationManager") - PendingIntent = autoclass("android.app.PendingIntent") - PythonActivity = autoclass("org.kivy.android.PythonActivity") - - if SDK_INT >= 26: - NotificationChannel = autoclass("android.app.NotificationChannel") - notification_service = cast( - NotificationManager, - get_context().getSystemService(Context.NOTIFICATION_SERVICE), - ) - channel_id = get_context().getPackageName() - app_channel = NotificationChannel( - channel_id, - "Kolibri Background Server", - NotificationManager.IMPORTANCE_DEFAULT, - ) - notification_service.createNotificationChannel(app_channel) - notification_builder = NotificationBuilder(app_context, channel_id) - else: - notification_builder = NotificationBuilder(app_context) - - notification_builder.setContentTitle(AndroidString(title)) - notification_builder.setContentText(AndroidString(message)) - notification_intent = Intent(app_context, PythonActivity) - notification_intent.setFlags( - Intent.FLAG_ACTIVITY_CLEAR_TOP - | Intent.FLAG_ACTIVITY_SINGLE_TOP - | Intent.FLAG_ACTIVITY_NEW_TASK - ) - notification_intent.setAction(Intent.ACTION_MAIN) - notification_intent.addCategory(Intent.CATEGORY_LAUNCHER) - intent = PendingIntent.getActivity(service, 0, notification_intent, 0) - notification_builder.setContentIntent(intent) - notification_builder.setSmallIcon(Drawable.icon) - notification_builder.setAutoCancel(True) - new_notification = notification_builder.getNotification() - service.startForeground(1, new_notification) - - def get_signature_key_issuer(): - PackageManager = autoclass("android.content.pm.PackageManager") - signature = get_package_info(flags=PackageManager.GET_SIGNATURES).signatures[0] + PythonContext = autoclass("org.kivy.android.PythonContext") + signature = PythonContext.getCertificateInfo() cert = x509.load_der_x509_certificate( - signature.toByteArray().tostring(), default_backend() + signature, default_backend() ) return cert.issuer.rfc4514_string() @@ -222,17 +150,13 @@ def get_dummy_user_name(): cache_key = "DUMMY_USER_NAME" value = value_cache.get(cache_key) if value is None: - Locale = autoclass("java.util.Locale") - currentLocale = Locale.getDefault().toLanguageTag() + PythonContext = autoclass("org.kivy.android.PythonContext") + currentLocale = PythonContext.getLocale() value = get_string("Learner", currentLocale) value_cache.set(cache_key, value) return value def is_active_network_metered(): - ConnectivityManager = autoclass("android.net.ConnectivityManager") - - return cast( - ConnectivityManager, - get_context().getSystemService(get_context().CONNECTIVITY_SERVICE), - ).isActiveNetworkMetered() + PythonContext = autoclass("org.kivy.android.PythonContext") + return PythonContext.isActiveNetworkMetered() diff --git a/src/initialization.py b/src/initialization.py index 52ada1f8..97637a89 100644 --- a/src/initialization.py +++ b/src/initialization.py @@ -4,12 +4,11 @@ import kolibri # noqa: F401 Import Kolibri here so we can import modules from dist folder import monkey_patch_zeroconf # noqa: F401 Import this to patch zeroconf -from android_utils import get_context from android_utils import get_home_folder +from android_utils import get_node_id from android_utils import get_signature_key_issuing_organization from android_utils import get_timezone_name from android_utils import get_version_name -from jnius import autoclass script_dir = os.path.dirname(os.path.abspath(__file__)) sys.path.append(script_dir) @@ -38,8 +37,7 @@ def set_node_id(): - Secure = autoclass("android.provider.Settings$Secure") - node_id = Secure.getString(get_context().getContentResolver(), Secure.ANDROID_ID) + node_id = get_node_id() # Don't set this if the retrieved id is falsy, too short, or a specific # id that is known to be hardcoded in many devices.