From 0f93e5d1ea5ea49b0c24cda27a5bc69c1572c80d Mon Sep 17 00:00:00 2001 From: Richard Tibbles Date: Thu, 2 Apr 2026 12:56:12 -0700 Subject: [PATCH 1/7] Remove obsolete python-for-android infrastructure Move reusable source files to new app/ layout via git mv. Delete kivy/renpy runtime, vendored jtar/jnius libraries, Docker build, Buildkite CI, old SQLite layer, and Sentinel/Reconciler/StateMap task infrastructure. Co-Authored-By: Claude Opus 4.6 (1M context) --- .buildkite/build.sh | 34 - .buildkite/pipeline.yaml | 10 - .dockerignore | 5 - .p4a | 18 - Dockerfile | 77 --- allowlist.txt | 9 - .../org/learningequality/Kolibri/App.java | 0 .../Kolibri/WorkController.java | 0 .../Kolibri/WorkControllerService.java | 0 .../Kolibri}/notification/Builder.java | 0 .../Kolibri}/notification/Manager.java | 0 .../notification/NotificationRef.java | 0 .../Kolibri}/notification/Notifier.java | 0 .../Kolibri}/task/Observable.java | 0 .../Kolibri}/task/Observer.java | 0 .../learningequality/Kolibri/task}/Task.java | 0 .../Kolibri/task/TaskWorkerImpl.java | 0 .../Kolibri/util}/ContextUtil.java | 0 .../Kolibri/util}/NetworkUtils.java | 0 .../Kolibri/workers}/BackgroundWorker.java | 0 .../Kolibri/workers}/ForegroundWorker.java | 0 {src => app/src/main/python}/main.py | 0 app/src/main/python/monkey_patch_zeroconf.py | 22 + {src => app/src/main/python}/taskworker.py | 2 + blocklist.txt | 49 -- p4a-recipes/kolibri/__init__.py | 29 - python-for-android/dists/kolibri/build.gradle | 116 ---- .../dists/kolibri/gradle.properties | 4 - .../kolibri/src/main/AndroidManifest.xml | 117 ---- .../org/jnius/NativeInvocationHandler.class | Bin 1084 -> 0 bytes .../org/jnius/NativeInvocationHandler.java | 41 -- .../main/java/org/kamranzafar/jtar/Octal.java | 141 ---- .../org/kamranzafar/jtar/TarConstants.java | 28 - .../java/org/kamranzafar/jtar/TarEntry.java | 284 -------- .../java/org/kamranzafar/jtar/TarHeader.java | 243 ------- .../org/kamranzafar/jtar/TarInputStream.java | 249 ------- .../org/kamranzafar/jtar/TarOutputStream.java | 163 ----- .../java/org/kamranzafar/jtar/TarUtils.java | 96 --- .../android/GenericBroadcastReceiver.java | 19 - .../GenericBroadcastReceiverCallback.java | 8 - .../java/org/kivy/android/PythonActivity.java | 615 ------------------ .../java/org/kivy/android/PythonContext.java | 33 - .../java/org/kivy/android/PythonLoader.java | 44 -- .../java/org/kivy/android/PythonProvider.java | 46 -- .../java/org/kivy/android/PythonService.java | 207 ------ .../java/org/kivy/android/PythonUtil.java | 267 -------- .../java/org/kivy/android/PythonWorker.java | 81 --- .../java/org/learningequality/FullScreen.java | 99 --- .../org/learningequality/FuturesUtil.java | 39 -- .../Kolibri/ServiceRemoteshell.java | 86 --- .../Kolibri/sqlite/JobStorage.java | 81 --- .../Kolibri/task/Builder.java | 194 ------ .../Kolibri/task/Reconciler.java | 208 ------ .../Kolibri/task/Sentinel.java | 251 ------- .../Kolibri/task/StateMap.java | 69 -- .../learningequality/MainThreadExecutor.java | 17 - .../org/learningequality/sqlite/Database.java | 104 --- .../sqlite/query/FilterableQuery.java | 49 -- .../learningequality/sqlite/query/Query.java | 10 - .../sqlite/query/SelectQuery.java | 113 ---- .../sqlite/query/UpdateQuery.java | 50 -- .../sqlite/schema/DatabaseTable.java | 64 -- .../org/learningequality/task/Worker.java | 159 ----- .../org/learningequality/task/WorkerImpl.java | 9 - .../java/org/renpy/android/AssetExtract.java | 117 ---- .../main/java/org/renpy/android/Hardware.java | 279 -------- .../org/renpy/android/ResourceManager.java | 53 -- .../dists/kolibri/src/main/jniLibs/.gitkeep | 0 .../ic_stat_kolibri_notification.png | Bin 1354 -> 0 bytes .../ic_stat_kolibri_notification.png | Bin 785 -> 0 bytes .../ic_stat_kolibri_notification.png | Bin 2028 -> 0 bytes .../ic_stat_kolibri_notification.png | Bin 3608 -> 0 bytes .../ic_stat_kolibri_notification.png | Bin 5421 -> 0 bytes .../kolibri/src/main/res/drawable/.gitkeep | 0 .../baseline_notifications_paused_24.xml | 5 - .../src/main/res/drawable/presplash.jpg | Bin 41593 -> 0 bytes .../kolibri/src/main/res/layout/main.xml | 12 - .../src/main/res/mipmap-anydpi-v26/.gitkeep | 0 .../kolibri/src/main/res/mipmap/.gitkeep | 0 .../kolibri/src/main/res/mipmap/icon.png | Bin 72353 -> 0 bytes .../kolibri/src/main/res/values/colors.xml | 4 - .../kolibri/src/main/res/values/strings.xml | 10 - .../dists/kolibri/src/main/res/xml/.gitkeep | 0 .../kolibri/src/main/res/xml/file_paths.xml | 5 - scripts/rundocker.sh | 50 -- setup.cfg | 4 - src/android_app_plugin/__init__.py | 0 src/android_app_plugin/kolibri_plugin.py | 116 ---- src/android_utils.py | 254 -------- src/i18n.py | 11 - src/initialization.py | 50 -- src/kolibri_app_settings.py | 8 - src/monkey_patch_zeroconf.py | 11 - src/remoteshell.py | 116 ---- src/runnable.py | 41 -- 95 files changed, 24 insertions(+), 5781 deletions(-) delete mode 100755 .buildkite/build.sh delete mode 100644 .buildkite/pipeline.yaml delete mode 100644 .dockerignore delete mode 100644 .p4a delete mode 100644 Dockerfile delete mode 100644 allowlist.txt rename {python-for-android/dists/kolibri => app}/src/main/java/org/learningequality/Kolibri/App.java (100%) rename {python-for-android/dists/kolibri => app}/src/main/java/org/learningequality/Kolibri/WorkController.java (100%) rename {python-for-android/dists/kolibri => app}/src/main/java/org/learningequality/Kolibri/WorkControllerService.java (100%) rename {python-for-android/dists/kolibri/src/main/java/org/learningequality => app/src/main/java/org/learningequality/Kolibri}/notification/Builder.java (100%) rename {python-for-android/dists/kolibri/src/main/java/org/learningequality => app/src/main/java/org/learningequality/Kolibri}/notification/Manager.java (100%) rename {python-for-android/dists/kolibri/src/main/java/org/learningequality => app/src/main/java/org/learningequality/Kolibri}/notification/NotificationRef.java (100%) rename {python-for-android/dists/kolibri/src/main/java/org/learningequality => app/src/main/java/org/learningequality/Kolibri}/notification/Notifier.java (100%) rename {python-for-android/dists/kolibri/src/main/java/org/learningequality => app/src/main/java/org/learningequality/Kolibri}/task/Observable.java (100%) rename {python-for-android/dists/kolibri/src/main/java/org/learningequality => app/src/main/java/org/learningequality/Kolibri}/task/Observer.java (100%) rename {python-for-android/dists/kolibri/src/main/java/org/learningequality => app/src/main/java/org/learningequality/Kolibri/task}/Task.java (100%) rename {python-for-android/dists/kolibri => app}/src/main/java/org/learningequality/Kolibri/task/TaskWorkerImpl.java (100%) rename {python-for-android/dists/kolibri/src/main/java/org/learningequality => app/src/main/java/org/learningequality/Kolibri/util}/ContextUtil.java (100%) rename {python-for-android/dists/kolibri/src/main/java/org/learningequality => app/src/main/java/org/learningequality/Kolibri/util}/NetworkUtils.java (100%) rename {python-for-android/dists/kolibri/src/main/java/org/learningequality/Kolibri => app/src/main/java/org/learningequality/Kolibri/workers}/BackgroundWorker.java (100%) rename {python-for-android/dists/kolibri/src/main/java/org/learningequality/Kolibri => app/src/main/java/org/learningequality/Kolibri/workers}/ForegroundWorker.java (100%) rename {src => app/src/main/python}/main.py (100%) create mode 100644 app/src/main/python/monkey_patch_zeroconf.py rename {src => app/src/main/python}/taskworker.py (97%) delete mode 100644 blocklist.txt delete mode 100644 p4a-recipes/kolibri/__init__.py delete mode 100644 python-for-android/dists/kolibri/build.gradle delete mode 100644 python-for-android/dists/kolibri/gradle.properties delete mode 100644 python-for-android/dists/kolibri/src/main/AndroidManifest.xml delete mode 100644 python-for-android/dists/kolibri/src/main/java/kolibri/org/jnius/NativeInvocationHandler.class delete mode 100644 python-for-android/dists/kolibri/src/main/java/kolibri/org/jnius/NativeInvocationHandler.java delete mode 100755 python-for-android/dists/kolibri/src/main/java/org/kamranzafar/jtar/Octal.java delete mode 100755 python-for-android/dists/kolibri/src/main/java/org/kamranzafar/jtar/TarConstants.java delete mode 100755 python-for-android/dists/kolibri/src/main/java/org/kamranzafar/jtar/TarEntry.java delete mode 100755 python-for-android/dists/kolibri/src/main/java/org/kamranzafar/jtar/TarHeader.java delete mode 100755 python-for-android/dists/kolibri/src/main/java/org/kamranzafar/jtar/TarInputStream.java delete mode 100755 python-for-android/dists/kolibri/src/main/java/org/kamranzafar/jtar/TarOutputStream.java delete mode 100755 python-for-android/dists/kolibri/src/main/java/org/kamranzafar/jtar/TarUtils.java delete mode 100644 python-for-android/dists/kolibri/src/main/java/org/kivy/android/GenericBroadcastReceiver.java delete mode 100644 python-for-android/dists/kolibri/src/main/java/org/kivy/android/GenericBroadcastReceiverCallback.java delete mode 100644 python-for-android/dists/kolibri/src/main/java/org/kivy/android/PythonActivity.java delete mode 100644 python-for-android/dists/kolibri/src/main/java/org/kivy/android/PythonContext.java delete mode 100644 python-for-android/dists/kolibri/src/main/java/org/kivy/android/PythonLoader.java delete mode 100644 python-for-android/dists/kolibri/src/main/java/org/kivy/android/PythonProvider.java delete mode 100644 python-for-android/dists/kolibri/src/main/java/org/kivy/android/PythonService.java delete mode 100644 python-for-android/dists/kolibri/src/main/java/org/kivy/android/PythonUtil.java delete mode 100644 python-for-android/dists/kolibri/src/main/java/org/kivy/android/PythonWorker.java delete mode 100644 python-for-android/dists/kolibri/src/main/java/org/learningequality/FullScreen.java delete mode 100644 python-for-android/dists/kolibri/src/main/java/org/learningequality/FuturesUtil.java delete mode 100644 python-for-android/dists/kolibri/src/main/java/org/learningequality/Kolibri/ServiceRemoteshell.java delete mode 100644 python-for-android/dists/kolibri/src/main/java/org/learningequality/Kolibri/sqlite/JobStorage.java delete mode 100644 python-for-android/dists/kolibri/src/main/java/org/learningequality/Kolibri/task/Builder.java delete mode 100644 python-for-android/dists/kolibri/src/main/java/org/learningequality/Kolibri/task/Reconciler.java delete mode 100644 python-for-android/dists/kolibri/src/main/java/org/learningequality/Kolibri/task/Sentinel.java delete mode 100644 python-for-android/dists/kolibri/src/main/java/org/learningequality/Kolibri/task/StateMap.java delete mode 100644 python-for-android/dists/kolibri/src/main/java/org/learningequality/MainThreadExecutor.java delete mode 100644 python-for-android/dists/kolibri/src/main/java/org/learningequality/sqlite/Database.java delete mode 100644 python-for-android/dists/kolibri/src/main/java/org/learningequality/sqlite/query/FilterableQuery.java delete mode 100644 python-for-android/dists/kolibri/src/main/java/org/learningequality/sqlite/query/Query.java delete mode 100644 python-for-android/dists/kolibri/src/main/java/org/learningequality/sqlite/query/SelectQuery.java delete mode 100644 python-for-android/dists/kolibri/src/main/java/org/learningequality/sqlite/query/UpdateQuery.java delete mode 100644 python-for-android/dists/kolibri/src/main/java/org/learningequality/sqlite/schema/DatabaseTable.java delete mode 100644 python-for-android/dists/kolibri/src/main/java/org/learningequality/task/Worker.java delete mode 100644 python-for-android/dists/kolibri/src/main/java/org/learningequality/task/WorkerImpl.java delete mode 100644 python-for-android/dists/kolibri/src/main/java/org/renpy/android/AssetExtract.java delete mode 100644 python-for-android/dists/kolibri/src/main/java/org/renpy/android/Hardware.java delete mode 100644 python-for-android/dists/kolibri/src/main/java/org/renpy/android/ResourceManager.java delete mode 100644 python-for-android/dists/kolibri/src/main/jniLibs/.gitkeep delete mode 100644 python-for-android/dists/kolibri/src/main/res/drawable-hdpi/ic_stat_kolibri_notification.png delete mode 100644 python-for-android/dists/kolibri/src/main/res/drawable-mdpi/ic_stat_kolibri_notification.png delete mode 100644 python-for-android/dists/kolibri/src/main/res/drawable-xhdpi/ic_stat_kolibri_notification.png delete mode 100644 python-for-android/dists/kolibri/src/main/res/drawable-xxhdpi/ic_stat_kolibri_notification.png delete mode 100644 python-for-android/dists/kolibri/src/main/res/drawable-xxxhdpi/ic_stat_kolibri_notification.png delete mode 100644 python-for-android/dists/kolibri/src/main/res/drawable/.gitkeep delete mode 100644 python-for-android/dists/kolibri/src/main/res/drawable/baseline_notifications_paused_24.xml delete mode 100644 python-for-android/dists/kolibri/src/main/res/drawable/presplash.jpg delete mode 100644 python-for-android/dists/kolibri/src/main/res/layout/main.xml delete mode 100644 python-for-android/dists/kolibri/src/main/res/mipmap-anydpi-v26/.gitkeep delete mode 100644 python-for-android/dists/kolibri/src/main/res/mipmap/.gitkeep delete mode 100644 python-for-android/dists/kolibri/src/main/res/mipmap/icon.png delete mode 100644 python-for-android/dists/kolibri/src/main/res/values/colors.xml delete mode 100644 python-for-android/dists/kolibri/src/main/res/values/strings.xml delete mode 100644 python-for-android/dists/kolibri/src/main/res/xml/.gitkeep delete mode 100644 python-for-android/dists/kolibri/src/main/res/xml/file_paths.xml delete mode 100755 scripts/rundocker.sh delete mode 100644 setup.cfg delete mode 100644 src/android_app_plugin/__init__.py delete mode 100644 src/android_app_plugin/kolibri_plugin.py delete mode 100644 src/android_utils.py delete mode 100644 src/i18n.py delete mode 100644 src/initialization.py delete mode 100644 src/kolibri_app_settings.py delete mode 100644 src/monkey_patch_zeroconf.py delete mode 100644 src/remoteshell.py delete mode 100644 src/runnable.py diff --git a/.buildkite/build.sh b/.buildkite/build.sh deleted file mode 100755 index 11308e5d..00000000 --- a/.buildkite/build.sh +++ /dev/null @@ -1,34 +0,0 @@ -#! /bin/bash -set -eo pipefail - - -echo "--- Downloading tar file" - -# Allows for building directly from pipeline or trigger -if [[ $LE_TRIGGERED_FROM_BUILD_ID ]] -then - echo "Downloading from triggered build" - buildkite-agent artifact download 'dist/*.tar.gz' . --build ${LE_TRIGGERED_FROM_BUILD_ID} - mv dist tar -else - echo "Downloading from pip" - TAR_DIR="/tmp/tar" - DOCKER_ID=$(docker create python:3 pip download -d $TAR_DIR kolibri) - docker start -a $DOCKER_ID - docker cp $DOCKER_ID:$TAR_DIR . - docker rm $DOCKER_ID -fi - -make run_docker - -# Making folder structure match other installers (convention) -mv ./dist/android/*.apk ./dist - -# if [[ $LE_TRIGGERED_FROM_JOB_ID && $BUILDKITE_TRIGGERED_FROM_BUILD_ID ]] -# then -# echo "--- Uploading artifact to parent job" -# buildkite-agent artifact upload dist/*.apk --job $LE_TRIGGERED_FROM_JOB_ID -# fi - -echo "--- Uploading artifact" -buildkite-agent artifact upload dist/*.apk diff --git a/.buildkite/pipeline.yaml b/.buildkite/pipeline.yaml deleted file mode 100644 index 21f9ed89..00000000 --- a/.buildkite/pipeline.yaml +++ /dev/null @@ -1,10 +0,0 @@ -steps: - - block: "Build APK?" - # The block step will only exist if the conditions below are true - # Only run triggered by the Kolibri pipeline and it's not a release - if: build.env("LE_KOLIBRI_RELEASE") == "false" || build.env("LE_TRIGGERED_FROM_KOLIBRI_VERSION_TAG") == "" - - - label: "Build Android APK :tada:" - command: ".buildkite/build.sh" - env: - KOLIBRI_ANDROID_BUILD_MODE: "release" diff --git a/.dockerignore b/.dockerignore deleted file mode 100644 index f3b3aa3b..00000000 --- a/.dockerignore +++ /dev/null @@ -1,5 +0,0 @@ -.gitignore -.buildozer -src/kolibri -dist/android -README.md diff --git a/.p4a b/.p4a deleted file mode 100644 index a2c409e8..00000000 --- a/.p4a +++ /dev/null @@ -1,18 +0,0 @@ ---window ---bootstrap "webview" ---package "org.learningequality.Kolibri" ---name "Kolibri" ---dist_name "kolibri" ---private "src" ---requirements python3==3.9.13,hostpython3==3.9.13,android,pyjnius,genericndkbuild,sqlite3,cryptography,twisted,attrs,bcrypt,service_identity,pyasn1,pyasn1_modules,pyopenssl,openssl,six,kolibri,ifaddr ---android-api 35 ---minsdk 23 ---ndk-api 23 ---permission ACCESS_NETWORK_STATE ---permission FOREGROUND_SERVICE ---worker TaskWorker:taskworker.py ---service remoteshell:remoteshell.py ---presplash-color #FFCB00 ---whitelist ./allowlist.txt ---blacklist ./blocklist.txt ---storage-dir ./python-for-android diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index 9e6a8d86..00000000 --- a/Dockerfile +++ /dev/null @@ -1,77 +0,0 @@ -FROM ubuntu:bionic as build -LABEL maintainer="Learning Equality " tag="kolibrikivy" -ENV DEBIAN_FRONTEND noninteractive - -# Install the dependencies for the build system -RUN dpkg --add-architecture i386 && \ - apt-get update && apt-get install -y \ - ant \ - autoconf \ - automake \ - build-essential \ - ccache \ - curl \ - cython \ - gcc \ - git \ - iproute2 \ - libffi-dev \ - libltdl-dev\ - libncurses5:i386 \ - libssl-dev \ - libstdc++6:i386 \ - libtool \ - locales \ - lsb-release \ - openjdk-11-jdk \ - python-dev \ - unzip \ - vim \ - wget \ - xclip \ - zip \ - xsel \ - zlib1g-dev \ - zlib1g:i386 \ - python-wxgtk3.0 \ - libgtk-3-dev \ - python3 \ - && apt-get clean - -ENV JAVA_HOME=/usr/lib/jvm/java-11-openjdk-amd64 -ENV PATH=$PATH:$JAVA_HOME - -RUN curl https://bootstrap.pypa.io/pip/3.6/get-pip.py -o get-pip.py && python3 get-pip.py - -# Ensure that python is using python3 -# copying approach from official python images -ENV PATH /usr/local/bin:$PATH -RUN cd /usr/local/bin && \ - ln -s $(which python3) python - -RUN sed -i -e 's/# en_US.UTF-8 UTF-8/en_US.UTF-8 UTF-8/' /etc/locale.gen && \ - locale-gen -ENV LC_ALL=en_US.UTF-8 LANG=en_US.UTF-8 - -# Install Android SDK -ENV ANDROID_SDK_ROOT=/opt/android -COPY Makefile /tmp/ -RUN make -C /tmp setup SDK=$ANDROIDSDK && \ - rm -f /tmp/Makefile - -# install python dependencies -COPY requirements.txt /tmp/ -RUN pip install -r /tmp/requirements.txt && \ - rm -f /tmp/requirements.txt - -# Configure gradle for use in docker. Disable gradle's automatically -# detected rich console doesn't work in docker. Disable the gradle -# daemon since it will be stopped as soon as the container exits. -ENV GRADLE_OPTS="-Dorg.gradle.console=plain -Dorg.gradle.daemon=false" - -# Create a mount point for the build cache and make it world writable so -# that the volume can be used by an unprivileged user without additional -# setup. -RUN mkdir /cache && chmod 777 /cache - -CMD [ "make", "kolibri.apk" ] diff --git a/allowlist.txt b/allowlist.txt deleted file mode 100644 index fc0fa20e..00000000 --- a/allowlist.txt +++ /dev/null @@ -1,9 +0,0 @@ -# Ensure that Django's SQLite backend module is included -sqlite3/* -lib-dynload/_sqlite3.so -unittest/* -wsgiref/* -lib-dynload/_csv.so -lib-dynload/_json.so -# Django REST framework has a dependency on the Django test module -django/test/* diff --git a/python-for-android/dists/kolibri/src/main/java/org/learningequality/Kolibri/App.java b/app/src/main/java/org/learningequality/Kolibri/App.java similarity index 100% rename from python-for-android/dists/kolibri/src/main/java/org/learningequality/Kolibri/App.java rename to app/src/main/java/org/learningequality/Kolibri/App.java diff --git a/python-for-android/dists/kolibri/src/main/java/org/learningequality/Kolibri/WorkController.java b/app/src/main/java/org/learningequality/Kolibri/WorkController.java similarity index 100% rename from python-for-android/dists/kolibri/src/main/java/org/learningequality/Kolibri/WorkController.java rename to app/src/main/java/org/learningequality/Kolibri/WorkController.java diff --git a/python-for-android/dists/kolibri/src/main/java/org/learningequality/Kolibri/WorkControllerService.java b/app/src/main/java/org/learningequality/Kolibri/WorkControllerService.java similarity index 100% rename from python-for-android/dists/kolibri/src/main/java/org/learningequality/Kolibri/WorkControllerService.java rename to app/src/main/java/org/learningequality/Kolibri/WorkControllerService.java diff --git a/python-for-android/dists/kolibri/src/main/java/org/learningequality/notification/Builder.java b/app/src/main/java/org/learningequality/Kolibri/notification/Builder.java similarity index 100% rename from python-for-android/dists/kolibri/src/main/java/org/learningequality/notification/Builder.java rename to app/src/main/java/org/learningequality/Kolibri/notification/Builder.java diff --git a/python-for-android/dists/kolibri/src/main/java/org/learningequality/notification/Manager.java b/app/src/main/java/org/learningequality/Kolibri/notification/Manager.java similarity index 100% rename from python-for-android/dists/kolibri/src/main/java/org/learningequality/notification/Manager.java rename to app/src/main/java/org/learningequality/Kolibri/notification/Manager.java diff --git a/python-for-android/dists/kolibri/src/main/java/org/learningequality/notification/NotificationRef.java b/app/src/main/java/org/learningequality/Kolibri/notification/NotificationRef.java similarity index 100% rename from python-for-android/dists/kolibri/src/main/java/org/learningequality/notification/NotificationRef.java rename to app/src/main/java/org/learningequality/Kolibri/notification/NotificationRef.java diff --git a/python-for-android/dists/kolibri/src/main/java/org/learningequality/notification/Notifier.java b/app/src/main/java/org/learningequality/Kolibri/notification/Notifier.java similarity index 100% rename from python-for-android/dists/kolibri/src/main/java/org/learningequality/notification/Notifier.java rename to app/src/main/java/org/learningequality/Kolibri/notification/Notifier.java diff --git a/python-for-android/dists/kolibri/src/main/java/org/learningequality/task/Observable.java b/app/src/main/java/org/learningequality/Kolibri/task/Observable.java similarity index 100% rename from python-for-android/dists/kolibri/src/main/java/org/learningequality/task/Observable.java rename to app/src/main/java/org/learningequality/Kolibri/task/Observable.java diff --git a/python-for-android/dists/kolibri/src/main/java/org/learningequality/task/Observer.java b/app/src/main/java/org/learningequality/Kolibri/task/Observer.java similarity index 100% rename from python-for-android/dists/kolibri/src/main/java/org/learningequality/task/Observer.java rename to app/src/main/java/org/learningequality/Kolibri/task/Observer.java diff --git a/python-for-android/dists/kolibri/src/main/java/org/learningequality/Task.java b/app/src/main/java/org/learningequality/Kolibri/task/Task.java similarity index 100% rename from python-for-android/dists/kolibri/src/main/java/org/learningequality/Task.java rename to app/src/main/java/org/learningequality/Kolibri/task/Task.java diff --git a/python-for-android/dists/kolibri/src/main/java/org/learningequality/Kolibri/task/TaskWorkerImpl.java b/app/src/main/java/org/learningequality/Kolibri/task/TaskWorkerImpl.java similarity index 100% rename from python-for-android/dists/kolibri/src/main/java/org/learningequality/Kolibri/task/TaskWorkerImpl.java rename to app/src/main/java/org/learningequality/Kolibri/task/TaskWorkerImpl.java diff --git a/python-for-android/dists/kolibri/src/main/java/org/learningequality/ContextUtil.java b/app/src/main/java/org/learningequality/Kolibri/util/ContextUtil.java similarity index 100% rename from python-for-android/dists/kolibri/src/main/java/org/learningequality/ContextUtil.java rename to app/src/main/java/org/learningequality/Kolibri/util/ContextUtil.java diff --git a/python-for-android/dists/kolibri/src/main/java/org/learningequality/NetworkUtils.java b/app/src/main/java/org/learningequality/Kolibri/util/NetworkUtils.java similarity index 100% rename from python-for-android/dists/kolibri/src/main/java/org/learningequality/NetworkUtils.java rename to app/src/main/java/org/learningequality/Kolibri/util/NetworkUtils.java diff --git a/python-for-android/dists/kolibri/src/main/java/org/learningequality/Kolibri/BackgroundWorker.java b/app/src/main/java/org/learningequality/Kolibri/workers/BackgroundWorker.java similarity index 100% rename from python-for-android/dists/kolibri/src/main/java/org/learningequality/Kolibri/BackgroundWorker.java rename to app/src/main/java/org/learningequality/Kolibri/workers/BackgroundWorker.java diff --git a/python-for-android/dists/kolibri/src/main/java/org/learningequality/Kolibri/ForegroundWorker.java b/app/src/main/java/org/learningequality/Kolibri/workers/ForegroundWorker.java similarity index 100% rename from python-for-android/dists/kolibri/src/main/java/org/learningequality/Kolibri/ForegroundWorker.java rename to app/src/main/java/org/learningequality/Kolibri/workers/ForegroundWorker.java diff --git a/src/main.py b/app/src/main/python/main.py similarity index 100% rename from src/main.py rename to app/src/main/python/main.py diff --git a/app/src/main/python/monkey_patch_zeroconf.py b/app/src/main/python/monkey_patch_zeroconf.py new file mode 100644 index 00000000..e94c7239 --- /dev/null +++ b/app/src/main/python/monkey_patch_zeroconf.py @@ -0,0 +1,22 @@ +import sys + +# kolibri import must come first: it puts its dist folder (zeroconf) on sys.path +import kolibri # noqa: F401 # isort: skip +import zeroconf +from jnius import autoclass + +NetworkUtils = autoclass("org.learningequality.NetworkUtils") + + +def get_all_addresses(): + return list(NetworkUtils.getActiveIPv4Addresses()) + + +# kolibri.utils.server binds get_all_addresses at import time; a stale (ifaddr) +# binding disagrees with the patched one, making ZeroConfPlugin.addresses_changed +# permanently True, so the broadcast restarts every 5s and discovery breaks. +assert "kolibri.utils.server" not in sys.modules, ( + "monkey_patch_zeroconf must be imported before kolibri.utils.server" +) + +zeroconf.get_all_addresses = get_all_addresses diff --git a/src/taskworker.py b/app/src/main/python/taskworker.py similarity index 97% rename from src/taskworker.py rename to app/src/main/python/taskworker.py index 7e9c66ad..ea585a7e 100644 --- a/src/taskworker.py +++ b/app/src/main/python/taskworker.py @@ -1,4 +1,6 @@ import logging +import os +import threading import initialization # noqa: F401 keep this first, to ensure we're set up for other imports from kolibri.main import enable_plugin diff --git a/blocklist.txt b/blocklist.txt deleted file mode 100644 index e392c2d5..00000000 --- a/blocklist.txt +++ /dev/null @@ -1,49 +0,0 @@ -# remove some assorted additional plugins -kolibri/plugins/demo_server/* - -# remove python2-only stuff -kolibri/dist/py2only/* - -# Remove cextensions -kolibri/dist/cext* - -# remove source maps -*.js.map - -# remove unused translation files from django and other apps -kolibri/dist/rest_framework/locale/* -kolibri/dist/django_filters/locale/* -kolibri/dist/mptt/locale/* - -kolibri/dist/django/contrib/admindocs/locale/* -kolibri/dist/django/contrib/auth/locale/* -kolibri/dist/django/contrib/sites/locale/* -kolibri/dist/django/contrib/contenttypes/locale/* -kolibri/dist/django/contrib/flatpages/locale/* -kolibri/dist/django/contrib/sessions/locale/* -kolibri/dist/django/contrib/humanize/locale/* -kolibri/dist/django/contrib/admin/locale/* - -# remove some django components entirely -kolibri/dist/django/contrib/gis/* -kolibri/dist/django/contrib/redirects/* -kolibri/dist/django/conf/app_template/* -kolibri/dist/django/conf/project_template/* -kolibri/dist/django/db/backends/postgresql_psycopg2/* -kolibri/dist/django/db/backends/postgresql/* -kolibri/dist/django/db/backends/mysql/* -kolibri/dist/django/db/backends/oracle/* -kolibri/dist/django/contrib/postgres/* - -# remove bigger chunks of django admin (may not want to do this) -kolibri/dist/django/contrib/admin/static/* -kolibri/dist/django/contrib/admin/templates/* - -# other assorted testing stuff -*/test/* -*/tests/* -kolibri/dist/tzlocal/test_data/* - -# remove some unnecessary apps -kolibri/dist/redis_cache/* -kolibri/dist/redis/* diff --git a/p4a-recipes/kolibri/__init__.py b/p4a-recipes/kolibri/__init__.py deleted file mode 100644 index 8485d53f..00000000 --- a/p4a-recipes/kolibri/__init__.py +++ /dev/null @@ -1,29 +0,0 @@ -from os import environ -from os.path import dirname -from os.path import join - -import kolibri -from pythonforandroid.recipe import PythonRecipe - - -class KolibriRecipe(PythonRecipe): - version = kolibri.__version__ - url = None - name = "kolibri" - # Needed because our setup.py depends on setuptools - # See https://github.com/kivy/python-for-android/issues/2078#issuecomment-754205392 - call_hostpython_via_targetpython = False - depends = ["python3", "setuptools"] - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - environ["P4A_{}_DIR".format(self.name.lower())] = join( - dirname(__file__), "../../tar/patched" - ) - - def should_build(self, arch): - # Always clean the build to ensure that we always update Kolibri - return True - - -recipe = KolibriRecipe() diff --git a/python-for-android/dists/kolibri/build.gradle b/python-for-android/dists/kolibri/build.gradle deleted file mode 100644 index ce780638..00000000 --- a/python-for-android/dists/kolibri/build.gradle +++ /dev/null @@ -1,116 +0,0 @@ -// Top-level build file where you can add configuration options common to all sub-projects/modules. -buildscript { - repositories { - google() - mavenCentral() - } - dependencies { - classpath 'com.android.tools.build:gradle:8.13.0' - } -} - -allprojects { - repositories { - google() - mavenCentral() - } -} - - -apply plugin: 'com.android.application' - - -android { - namespace = 'org.learningequality.Kolibri' - compileSdkVersion = 35 - buildToolsVersion = "35.0.0" - def versionPropsFile = file('version.properties') - Properties versionProps = new Properties() - - if (versionPropsFile.canRead()) { - versionProps.load(new FileInputStream(versionPropsFile)) - } - else { - throw AssertionError("version.properties has not been defined") - } - - def code = versionProps['VERSION_CODE'].toInteger() - def name = versionProps['VERSION_NAME'] - - // If we are doing a debug build, we'll end up with -debug-debug - // so we strip that here. - // For release builds, we'll either have -dev-release - // or -official-release so it is more informative. - def nameNoDebug = name.replace("-debug", "") - - defaultConfig { - minSdkVersion = 23 - targetSdk = 35 - versionCode = code - versionName = name - manifestPlaceholders = [:] - multiDexEnabled = true - setProperty("archivesBaseName", "kolibri-$nameNoDebug") - buildConfigField "String", "VERSION_CODE", "\"${code.toString()}\"" - } - - packagingOptions { - jniLibs { - useLegacyPackaging = true - } - resources { - excludes += ['lib/**/gdbserver', 'lib/**/gdb.setup'] - } - } - - signingConfigs { - release { - storeFile file(System.getenv("RELEASE_KEYSTORE") ?: "empty") - keyAlias System.getenv("RELEASE_KEYALIAS") ?: "" - storePassword System.getenv("RELEASE_KEYSTORE_PASSWD") ?: "" - keyPassword System.getenv("RELEASE_KEYALIAS_PASSWD") ?: "" - } - } - - buildTypes { - debug { - debuggable = true - } - release { - signingConfig = signingConfigs.release - } - } - - compileOptions { - - sourceCompatibility = JavaVersion.VERSION_1_8 - targetCompatibility = JavaVersion.VERSION_1_8 - - } - - sourceSets { - main { - jniLibs.srcDir 'libs' - } - } - - - buildFeatures { - buildConfig = true - } - androidResources { - noCompress 'tflite' - } - -} - -dependencies { - implementation 'com.android.support:support-v4:28.0.0' - implementation 'com.android.support:multidex:1.0.3' - implementation 'androidx.annotation:annotation:1.9.1' - implementation 'androidx.concurrent:concurrent-futures:1.3.0' - implementation 'androidx.work:work-runtime:2.11.0' - implementation 'androidx.work:work-multiprocess:2.11.0' - implementation "androidx.lifecycle:lifecycle-service:2.9.4" - implementation 'net.sourceforge.streamsupport:java9-concurrent-backport:2.0.5' -} diff --git a/python-for-android/dists/kolibri/gradle.properties b/python-for-android/dists/kolibri/gradle.properties deleted file mode 100644 index ccc77df0..00000000 --- a/python-for-android/dists/kolibri/gradle.properties +++ /dev/null @@ -1,4 +0,0 @@ - -android.useAndroidX=true -android.enableJetifier=true -org.gradle.jvmargs=-Xmx4608m diff --git a/python-for-android/dists/kolibri/src/main/AndroidManifest.xml b/python-for-android/dists/kolibri/src/main/AndroidManifest.xml deleted file mode 100644 index cba40cdd..00000000 --- a/python-for-android/dists/kolibri/src/main/AndroidManifest.xml +++ /dev/null @@ -1,117 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/python-for-android/dists/kolibri/src/main/java/kolibri/org/jnius/NativeInvocationHandler.class b/python-for-android/dists/kolibri/src/main/java/kolibri/org/jnius/NativeInvocationHandler.class deleted file mode 100644 index 2c7571e2400bf7f442df41af223035e6025ed463..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1084 zcmZ`%+fvg|6kVscq$OMgxyh{vXj=-w;vdi*=z5$v)9V`_2p!&lD)#2bNd-E(!PA~DN8boO{CrB-*1Lqo3_c2$3|wQH8W!WqOhw`+v- zHPhR1DuuUaO0DMfc}SqMxO3?n#kJ4$>2gN#GWG6?el5}EH1nTc-*eP$*w%-H2u;)o!DI68=4!bpqvfoPOHo5u*h zhklGmZ?uUR-`OS, "); - // don't call it, or recursive lookup/proxy will go! - //System.out.print(proxy); - //System.out.print(", "); - System.out.print(method); - System.out.print(", "); - System.out.print(args); - System.out.println(")"); - System.out.flush(); - } - - Object ret = invoke0(proxy, method, args); - - if ( DEBUG ) { - System.out.print("+ java:invoke returned: "); - System.out.println(ret); - } - - return ret; - } - - public long getPythonObjectPointer() { - return ptr; - } - - native Object invoke0(Object proxy, Method method, Object[] args); -} diff --git a/python-for-android/dists/kolibri/src/main/java/org/kamranzafar/jtar/Octal.java b/python-for-android/dists/kolibri/src/main/java/org/kamranzafar/jtar/Octal.java deleted file mode 100755 index 9d1d6651..00000000 --- a/python-for-android/dists/kolibri/src/main/java/org/kamranzafar/jtar/Octal.java +++ /dev/null @@ -1,141 +0,0 @@ -/** - * Copyright 2012 Kamran Zafar - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - * - */ - -package org.kamranzafar.jtar; - -/** - * @author Kamran Zafar - * - */ -public class Octal { - - /** - * Parse an octal string from a header buffer. This is used for the file - * permission mode value. - * - * @param header - * The header buffer from which to parse. - * @param offset - * The offset into the buffer from which to parse. - * @param length - * The number of header bytes to parse. - * - * @return The long value of the octal string. - */ - public static long parseOctal(byte[] header, int offset, int length) { - long result = 0; - boolean stillPadding = true; - - int end = offset + length; - for (int i = offset; i < end; ++i) { - if (header[i] == 0) - break; - - if (header[i] == (byte) ' ' || header[i] == '0') { - if (stillPadding) - continue; - - if (header[i] == (byte) ' ') - break; - } - - stillPadding = false; - - result = ( result << 3 ) + ( header[i] - '0' ); - } - - return result; - } - - /** - * Parse an octal integer from a header buffer. - * - * @param value - * @param buf - * The header buffer from which to parse. - * @param offset - * The offset into the buffer from which to parse. - * @param length - * The number of header bytes to parse. - * - * @return The integer value of the octal bytes. - */ - public static int getOctalBytes(long value, byte[] buf, int offset, int length) { - int idx = length - 1; - - buf[offset + idx] = 0; - --idx; - buf[offset + idx] = (byte) ' '; - --idx; - - if (value == 0) { - buf[offset + idx] = (byte) '0'; - --idx; - } else { - for (long val = value; idx >= 0 && val > 0; --idx) { - buf[offset + idx] = (byte) ( (byte) '0' + (byte) ( val & 7 ) ); - val = val >> 3; - } - } - - for (; idx >= 0; --idx) { - buf[offset + idx] = (byte) ' '; - } - - return offset + length; - } - - /** - * Parse the checksum octal integer from a header buffer. - * - * @param value - * @param buf - * The header buffer from which to parse. - * @param offset - * The offset into the buffer from which to parse. - * @param length - * The number of header bytes to parse. - * @return The integer value of the entry's checksum. - */ - public static int getCheckSumOctalBytes(long value, byte[] buf, int offset, int length) { - getOctalBytes( value, buf, offset, length ); - buf[offset + length - 1] = (byte) ' '; - buf[offset + length - 2] = 0; - return offset + length; - } - - /** - * Parse an octal long integer from a header buffer. - * - * @param value - * @param buf - * The header buffer from which to parse. - * @param offset - * The offset into the buffer from which to parse. - * @param length - * The number of header bytes to parse. - * - * @return The long value of the octal bytes. - */ - public static int getLongOctalBytes(long value, byte[] buf, int offset, int length) { - byte[] temp = new byte[length + 1]; - getOctalBytes( value, temp, 0, length + 1 ); - System.arraycopy( temp, 0, buf, offset, length ); - return offset + length; - } - -} diff --git a/python-for-android/dists/kolibri/src/main/java/org/kamranzafar/jtar/TarConstants.java b/python-for-android/dists/kolibri/src/main/java/org/kamranzafar/jtar/TarConstants.java deleted file mode 100755 index 90b4daf2..00000000 --- a/python-for-android/dists/kolibri/src/main/java/org/kamranzafar/jtar/TarConstants.java +++ /dev/null @@ -1,28 +0,0 @@ -/** - * Copyright 2012 Kamran Zafar - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - * - */ - -package org.kamranzafar.jtar; - -/** - * @author Kamran Zafar - * - */ -public class TarConstants { - public static final int EOF_BLOCK = 1024; - public static final int DATA_BLOCK = 512; - public static final int HEADER_BLOCK = 512; -} diff --git a/python-for-android/dists/kolibri/src/main/java/org/kamranzafar/jtar/TarEntry.java b/python-for-android/dists/kolibri/src/main/java/org/kamranzafar/jtar/TarEntry.java deleted file mode 100755 index a97579ae..00000000 --- a/python-for-android/dists/kolibri/src/main/java/org/kamranzafar/jtar/TarEntry.java +++ /dev/null @@ -1,284 +0,0 @@ -/** - * Copyright 2012 Kamran Zafar - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - * - */ - -package org.kamranzafar.jtar; - -import java.io.File; -import java.util.Date; - -/** - * @author Kamran Zafar - * - */ -public class TarEntry { - protected File file; - protected TarHeader header; - - private TarEntry() { - this.file = null; - header = new TarHeader(); - } - - public TarEntry(File file, String entryName) { - this(); - this.file = file; - this.extractTarHeader(entryName); - } - - public TarEntry(byte[] headerBuf) { - this(); - this.parseTarHeader(headerBuf); - } - - /** - * Constructor to create an entry from an existing TarHeader object. - * - * This method is useful to add new entries programmatically (e.g. for - * adding files or directories that do not exist in the file system). - * - * @param header - * - */ - public TarEntry(TarHeader header) { - this.file = null; - this.header = header; - } - - public boolean equals(TarEntry it) { - return header.name.toString().equals(it.header.name.toString()); - } - - public boolean isDescendent(TarEntry desc) { - return desc.header.name.toString().startsWith(header.name.toString()); - } - - public TarHeader getHeader() { - return header; - } - - public String getName() { - String name = header.name.toString(); - if (header.namePrefix != null && !header.namePrefix.toString().equals("")) { - name = header.namePrefix.toString() + "/" + name; - } - - return name; - } - - public void setName(String name) { - header.name = new StringBuffer(name); - } - - public int getUserId() { - return header.userId; - } - - public void setUserId(int userId) { - header.userId = userId; - } - - public int getGroupId() { - return header.groupId; - } - - public void setGroupId(int groupId) { - header.groupId = groupId; - } - - public String getUserName() { - return header.userName.toString(); - } - - public void setUserName(String userName) { - header.userName = new StringBuffer(userName); - } - - public String getGroupName() { - return header.groupName.toString(); - } - - public void setGroupName(String groupName) { - header.groupName = new StringBuffer(groupName); - } - - public void setIds(int userId, int groupId) { - this.setUserId(userId); - this.setGroupId(groupId); - } - - public void setModTime(long time) { - header.modTime = time / 1000; - } - - public void setModTime(Date time) { - header.modTime = time.getTime() / 1000; - } - - public Date getModTime() { - return new Date(header.modTime * 1000); - } - - public File getFile() { - return this.file; - } - - public long getSize() { - return header.size; - } - - public void setSize(long size) { - header.size = size; - } - - /** - * Checks if the org.kamrazafar.jtar entry is a directory - * - * @return - */ - public boolean isDirectory() { - if (this.file != null) - return this.file.isDirectory(); - - if (header != null) { - if (header.linkFlag == TarHeader.LF_DIR) - return true; - - if (header.name.toString().endsWith("/")) - return true; - } - - return false; - } - - /** - * Extract header from File - * - * @param entryName - */ - public void extractTarHeader(String entryName) { - header = TarHeader.createHeader(entryName, file.length(), file.lastModified() / 1000, file.isDirectory()); - } - - /** - * Calculate checksum - * - * @param buf - * @return - */ - public long computeCheckSum(byte[] buf) { - long sum = 0; - - for (int i = 0; i < buf.length; ++i) { - sum += 255 & buf[i]; - } - - return sum; - } - - /** - * Writes the header to the byte buffer - * - * @param outbuf - */ - public void writeEntryHeader(byte[] outbuf) { - int offset = 0; - - offset = TarHeader.getNameBytes(header.name, outbuf, offset, TarHeader.NAMELEN); - offset = Octal.getOctalBytes(header.mode, outbuf, offset, TarHeader.MODELEN); - offset = Octal.getOctalBytes(header.userId, outbuf, offset, TarHeader.UIDLEN); - offset = Octal.getOctalBytes(header.groupId, outbuf, offset, TarHeader.GIDLEN); - - long size = header.size; - - offset = Octal.getLongOctalBytes(size, outbuf, offset, TarHeader.SIZELEN); - offset = Octal.getLongOctalBytes(header.modTime, outbuf, offset, TarHeader.MODTIMELEN); - - int csOffset = offset; - for (int c = 0; c < TarHeader.CHKSUMLEN; ++c) - outbuf[offset++] = (byte) ' '; - - outbuf[offset++] = header.linkFlag; - - offset = TarHeader.getNameBytes(header.linkName, outbuf, offset, TarHeader.NAMELEN); - offset = TarHeader.getNameBytes(header.magic, outbuf, offset, TarHeader.USTAR_MAGICLEN); - offset = TarHeader.getNameBytes(header.userName, outbuf, offset, TarHeader.USTAR_USER_NAMELEN); - offset = TarHeader.getNameBytes(header.groupName, outbuf, offset, TarHeader.USTAR_GROUP_NAMELEN); - offset = Octal.getOctalBytes(header.devMajor, outbuf, offset, TarHeader.USTAR_DEVLEN); - offset = Octal.getOctalBytes(header.devMinor, outbuf, offset, TarHeader.USTAR_DEVLEN); - offset = TarHeader.getNameBytes(header.namePrefix, outbuf, offset, TarHeader.USTAR_FILENAME_PREFIX); - - for (; offset < outbuf.length;) - outbuf[offset++] = 0; - - long checkSum = this.computeCheckSum(outbuf); - - Octal.getCheckSumOctalBytes(checkSum, outbuf, csOffset, TarHeader.CHKSUMLEN); - } - - /** - * Parses the tar header to the byte buffer - * - * @param header - * @param bh - */ - public void parseTarHeader(byte[] bh) { - int offset = 0; - - header.name = TarHeader.parseName(bh, offset, TarHeader.NAMELEN); - offset += TarHeader.NAMELEN; - - header.mode = (int) Octal.parseOctal(bh, offset, TarHeader.MODELEN); - offset += TarHeader.MODELEN; - - header.userId = (int) Octal.parseOctal(bh, offset, TarHeader.UIDLEN); - offset += TarHeader.UIDLEN; - - header.groupId = (int) Octal.parseOctal(bh, offset, TarHeader.GIDLEN); - offset += TarHeader.GIDLEN; - - header.size = Octal.parseOctal(bh, offset, TarHeader.SIZELEN); - offset += TarHeader.SIZELEN; - - header.modTime = Octal.parseOctal(bh, offset, TarHeader.MODTIMELEN); - offset += TarHeader.MODTIMELEN; - - header.checkSum = (int) Octal.parseOctal(bh, offset, TarHeader.CHKSUMLEN); - offset += TarHeader.CHKSUMLEN; - - header.linkFlag = bh[offset++]; - - header.linkName = TarHeader.parseName(bh, offset, TarHeader.NAMELEN); - offset += TarHeader.NAMELEN; - - header.magic = TarHeader.parseName(bh, offset, TarHeader.USTAR_MAGICLEN); - offset += TarHeader.USTAR_MAGICLEN; - - header.userName = TarHeader.parseName(bh, offset, TarHeader.USTAR_USER_NAMELEN); - offset += TarHeader.USTAR_USER_NAMELEN; - - header.groupName = TarHeader.parseName(bh, offset, TarHeader.USTAR_GROUP_NAMELEN); - offset += TarHeader.USTAR_GROUP_NAMELEN; - - header.devMajor = (int) Octal.parseOctal(bh, offset, TarHeader.USTAR_DEVLEN); - offset += TarHeader.USTAR_DEVLEN; - - header.devMinor = (int) Octal.parseOctal(bh, offset, TarHeader.USTAR_DEVLEN); - offset += TarHeader.USTAR_DEVLEN; - - header.namePrefix = TarHeader.parseName(bh, offset, TarHeader.USTAR_FILENAME_PREFIX); - } -} diff --git a/python-for-android/dists/kolibri/src/main/java/org/kamranzafar/jtar/TarHeader.java b/python-for-android/dists/kolibri/src/main/java/org/kamranzafar/jtar/TarHeader.java deleted file mode 100755 index 52610e2c..00000000 --- a/python-for-android/dists/kolibri/src/main/java/org/kamranzafar/jtar/TarHeader.java +++ /dev/null @@ -1,243 +0,0 @@ -/** - * Copyright 2012 Kamran Zafar - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - * - */ - -package org.kamranzafar.jtar; - -import java.io.File; - -/** - * Header - * - *
- * Offset  Size     Field
- * 0       100      File name
- * 100     8        File mode
- * 108     8        Owner's numeric user ID
- * 116     8        Group's numeric user ID
- * 124     12       File size in bytes
- * 136     12       Last modification time in numeric Unix time format
- * 148     8        Checksum for header block
- * 156     1        Link indicator (file type)
- * 157     100      Name of linked file
- * 
- * - * - * File Types - * - *
- * Value        Meaning
- * '0'          Normal file
- * (ASCII NUL)  Normal file (now obsolete)
- * '1'          Hard link
- * '2'          Symbolic link
- * '3'          Character special
- * '4'          Block special
- * '5'          Directory
- * '6'          FIFO
- * '7'          Contigous
- * 
- * - * - * - * Ustar header - * - *
- * Offset  Size    Field
- * 257     6       UStar indicator "ustar"
- * 263     2       UStar version "00"
- * 265     32      Owner user name
- * 297     32      Owner group name
- * 329     8       Device major number
- * 337     8       Device minor number
- * 345     155     Filename prefix
- * 
- */ - -public class TarHeader { - - /* - * Header - */ - public static final int NAMELEN = 100; - public static final int MODELEN = 8; - public static final int UIDLEN = 8; - public static final int GIDLEN = 8; - public static final int SIZELEN = 12; - public static final int MODTIMELEN = 12; - public static final int CHKSUMLEN = 8; - public static final byte LF_OLDNORM = 0; - - /* - * File Types - */ - public static final byte LF_NORMAL = (byte) '0'; - public static final byte LF_LINK = (byte) '1'; - public static final byte LF_SYMLINK = (byte) '2'; - public static final byte LF_CHR = (byte) '3'; - public static final byte LF_BLK = (byte) '4'; - public static final byte LF_DIR = (byte) '5'; - public static final byte LF_FIFO = (byte) '6'; - public static final byte LF_CONTIG = (byte) '7'; - - /* - * Ustar header - */ - - public static final String USTAR_MAGIC = "ustar"; // POSIX - - public static final int USTAR_MAGICLEN = 8; - public static final int USTAR_USER_NAMELEN = 32; - public static final int USTAR_GROUP_NAMELEN = 32; - public static final int USTAR_DEVLEN = 8; - public static final int USTAR_FILENAME_PREFIX = 155; - - // Header values - public StringBuffer name; - public int mode; - public int userId; - public int groupId; - public long size; - public long modTime; - public int checkSum; - public byte linkFlag; - public StringBuffer linkName; - public StringBuffer magic; // ustar indicator and version - public StringBuffer userName; - public StringBuffer groupName; - public int devMajor; - public int devMinor; - public StringBuffer namePrefix; - - public TarHeader() { - this.magic = new StringBuffer(TarHeader.USTAR_MAGIC); - - this.name = new StringBuffer(); - this.linkName = new StringBuffer(); - - String user = System.getProperty("user.name", ""); - - if (user.length() > 31) - user = user.substring(0, 31); - - this.userId = 0; - this.groupId = 0; - this.userName = new StringBuffer(user); - this.groupName = new StringBuffer(""); - this.namePrefix = new StringBuffer(); - } - - /** - * Parse an entry name from a header buffer. - * - * @param name - * @param header - * The header buffer from which to parse. - * @param offset - * The offset into the buffer from which to parse. - * @param length - * The number of header bytes to parse. - * @return The header's entry name. - */ - public static StringBuffer parseName(byte[] header, int offset, int length) { - StringBuffer result = new StringBuffer(length); - - int end = offset + length; - for (int i = offset; i < end; ++i) { - if (header[i] == 0) - break; - result.append((char) header[i]); - } - - return result; - } - - /** - * Determine the number of bytes in an entry name. - * - * @param name - * @param header - * The header buffer from which to parse. - * @param offset - * The offset into the buffer from which to parse. - * @param length - * The number of header bytes to parse. - * @return The number of bytes in a header's entry name. - */ - public static int getNameBytes(StringBuffer name, byte[] buf, int offset, int length) { - int i; - - for (i = 0; i < length && i < name.length(); ++i) { - buf[offset + i] = (byte) name.charAt(i); - } - - for (; i < length; ++i) { - buf[offset + i] = 0; - } - - return offset + length; - } - - /** - * Creates a new header for a file/directory entry. - * - * - * @param name - * File name - * @param size - * File size in bytes - * @param modTime - * Last modification time in numeric Unix time format - * @param dir - * Is directory - * - * @return - */ - public static TarHeader createHeader(String entryName, long size, long modTime, boolean dir) { - String name = entryName; - name = TarUtils.trim(name.replace(File.separatorChar, '/'), '/'); - - TarHeader header = new TarHeader(); - header.linkName = new StringBuffer(""); - - if (name.length() > 100) { - header.namePrefix = new StringBuffer(name.substring(0, name.lastIndexOf('/'))); - header.name = new StringBuffer(name.substring(name.lastIndexOf('/') + 1)); - } else { - header.name = new StringBuffer(name); - } - - if (dir) { - header.mode = 040755; - header.linkFlag = TarHeader.LF_DIR; - if (header.name.charAt(header.name.length() - 1) != '/') { - header.name.append("/"); - } - header.size = 0; - } else { - header.mode = 0100644; - header.linkFlag = TarHeader.LF_NORMAL; - header.size = size; - } - - header.modTime = modTime; - header.checkSum = 0; - header.devMajor = 0; - header.devMinor = 0; - - return header; - } -} diff --git a/python-for-android/dists/kolibri/src/main/java/org/kamranzafar/jtar/TarInputStream.java b/python-for-android/dists/kolibri/src/main/java/org/kamranzafar/jtar/TarInputStream.java deleted file mode 100755 index f2ba0884..00000000 --- a/python-for-android/dists/kolibri/src/main/java/org/kamranzafar/jtar/TarInputStream.java +++ /dev/null @@ -1,249 +0,0 @@ -/** - * Copyright 2012 Kamran Zafar - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - * - */ - -package org.kamranzafar.jtar; - -import java.io.FilterInputStream; -import java.io.IOException; -import java.io.InputStream; - -/** - * @author Kamran Zafar - * - */ -public class TarInputStream extends FilterInputStream { - - private static final int SKIP_BUFFER_SIZE = 2048; - private TarEntry currentEntry; - private long currentFileSize; - private long bytesRead; - private boolean defaultSkip = false; - - public TarInputStream(InputStream in) { - super(in); - currentFileSize = 0; - bytesRead = 0; - } - - @Override - public boolean markSupported() { - return false; - } - - /** - * Not supported - * - */ - @Override - public synchronized void mark(int readlimit) { - } - - /** - * Not supported - * - */ - @Override - public synchronized void reset() throws IOException { - throw new IOException("mark/reset not supported"); - } - - /** - * Read a byte - * - * @see java.io.FilterInputStream#read() - */ - @Override - public int read() throws IOException { - byte[] buf = new byte[1]; - - int res = this.read(buf, 0, 1); - - if (res != -1) { - return 0xFF & buf[0]; - } - - return res; - } - - /** - * Checks if the bytes being read exceed the entry size and adjusts the byte - * array length. Updates the byte counters - * - * - * @see java.io.FilterInputStream#read(byte[], int, int) - */ - @Override - public int read(byte[] b, int off, int len) throws IOException { - if (currentEntry != null) { - if (currentFileSize == currentEntry.getSize()) { - return -1; - } else if ((currentEntry.getSize() - currentFileSize) < len) { - len = (int) (currentEntry.getSize() - currentFileSize); - } - } - - int br = super.read(b, off, len); - - if (br != -1) { - if (currentEntry != null) { - currentFileSize += br; - } - - bytesRead += br; - } - - return br; - } - - /** - * Returns the next entry in the tar file - * - * @return TarEntry - * @throws IOException - */ - public TarEntry getNextEntry() throws IOException { - closeCurrentEntry(); - - byte[] header = new byte[TarConstants.HEADER_BLOCK]; - byte[] theader = new byte[TarConstants.HEADER_BLOCK]; - int tr = 0; - - // Read full header - while (tr < TarConstants.HEADER_BLOCK) { - int res = read(theader, 0, TarConstants.HEADER_BLOCK - tr); - - if (res < 0) { - break; - } - - System.arraycopy(theader, 0, header, tr, res); - tr += res; - } - - // Check if record is null - boolean eof = true; - for (byte b : header) { - if (b != 0) { - eof = false; - break; - } - } - - if (!eof) { - currentEntry = new TarEntry(header); - } - - return currentEntry; - } - - /** - * Returns the current offset (in bytes) from the beginning of the stream. - * This can be used to find out at which point in a tar file an entry's content begins, for instance. - */ - public long getCurrentOffset() { - return bytesRead; - } - - /** - * Closes the current tar entry - * - * @throws IOException - */ - protected void closeCurrentEntry() throws IOException { - if (currentEntry != null) { - if (currentEntry.getSize() > currentFileSize) { - // Not fully read, skip rest of the bytes - long bs = 0; - while (bs < currentEntry.getSize() - currentFileSize) { - long res = skip(currentEntry.getSize() - currentFileSize - bs); - - if (res == 0 && currentEntry.getSize() - currentFileSize > 0) { - // I suspect file corruption - throw new IOException("Possible tar file corruption"); - } - - bs += res; - } - } - - currentEntry = null; - currentFileSize = 0L; - skipPad(); - } - } - - /** - * Skips the pad at the end of each tar entry file content - * - * @throws IOException - */ - protected void skipPad() throws IOException { - if (bytesRead > 0) { - int extra = (int) (bytesRead % TarConstants.DATA_BLOCK); - - if (extra > 0) { - long bs = 0; - while (bs < TarConstants.DATA_BLOCK - extra) { - long res = skip(TarConstants.DATA_BLOCK - extra - bs); - bs += res; - } - } - } - } - - /** - * Skips 'n' bytes on the InputStream
- * Overrides default implementation of skip - * - */ - @Override - public long skip(long n) throws IOException { - if (defaultSkip) { - // use skip method of parent stream - // may not work if skip not implemented by parent - long bs = super.skip(n); - bytesRead += bs; - - return bs; - } - - if (n <= 0) { - return 0; - } - - long left = n; - byte[] sBuff = new byte[SKIP_BUFFER_SIZE]; - - while (left > 0) { - int res = read(sBuff, 0, (int) (left < SKIP_BUFFER_SIZE ? left : SKIP_BUFFER_SIZE)); - if (res < 0) { - break; - } - left -= res; - } - - return n - left; - } - - public boolean isDefaultSkip() { - return defaultSkip; - } - - public void setDefaultSkip(boolean defaultSkip) { - this.defaultSkip = defaultSkip; - } -} diff --git a/python-for-android/dists/kolibri/src/main/java/org/kamranzafar/jtar/TarOutputStream.java b/python-for-android/dists/kolibri/src/main/java/org/kamranzafar/jtar/TarOutputStream.java deleted file mode 100755 index 8bd1c6b3..00000000 --- a/python-for-android/dists/kolibri/src/main/java/org/kamranzafar/jtar/TarOutputStream.java +++ /dev/null @@ -1,163 +0,0 @@ -/** - * Copyright 2012 Kamran Zafar - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - * - */ - -package org.kamranzafar.jtar; - -import java.io.BufferedOutputStream; -import java.io.File; -import java.io.FileNotFoundException; -import java.io.FileOutputStream; -import java.io.IOException; -import java.io.OutputStream; -import java.io.RandomAccessFile; - -/** - * @author Kamran Zafar - * - */ -public class TarOutputStream extends OutputStream { - private final OutputStream out; - private long bytesWritten; - private long currentFileSize; - private TarEntry currentEntry; - - public TarOutputStream(OutputStream out) { - this.out = out; - bytesWritten = 0; - currentFileSize = 0; - } - - public TarOutputStream(final File fout) throws FileNotFoundException { - this.out = new BufferedOutputStream(new FileOutputStream(fout)); - bytesWritten = 0; - currentFileSize = 0; - } - - /** - * Opens a file for writing. - */ - public TarOutputStream(final File fout, final boolean append) throws IOException { - @SuppressWarnings("resource") - RandomAccessFile raf = new RandomAccessFile(fout, "rw"); - final long fileSize = fout.length(); - if (append && fileSize > TarConstants.EOF_BLOCK) { - raf.seek(fileSize - TarConstants.EOF_BLOCK); - } - out = new BufferedOutputStream(new FileOutputStream(raf.getFD())); - } - - /** - * Appends the EOF record and closes the stream - * - * @see java.io.FilterOutputStream#close() - */ - @Override - public void close() throws IOException { - closeCurrentEntry(); - write( new byte[TarConstants.EOF_BLOCK] ); - out.close(); - } - /** - * Writes a byte to the stream and updates byte counters - * - * @see java.io.FilterOutputStream#write(int) - */ - @Override - public void write(int b) throws IOException { - out.write( b ); - bytesWritten += 1; - - if (currentEntry != null) { - currentFileSize += 1; - } - } - - /** - * Checks if the bytes being written exceed the current entry size. - * - * @see java.io.FilterOutputStream#write(byte[], int, int) - */ - @Override - public void write(byte[] b, int off, int len) throws IOException { - if (currentEntry != null && !currentEntry.isDirectory()) { - if (currentEntry.getSize() < currentFileSize + len) { - throw new IOException( "The current entry[" + currentEntry.getName() + "] size[" - + currentEntry.getSize() + "] is smaller than the bytes[" + ( currentFileSize + len ) - + "] being written." ); - } - } - - out.write( b, off, len ); - - bytesWritten += len; - - if (currentEntry != null) { - currentFileSize += len; - } - } - - /** - * Writes the next tar entry header on the stream - * - * @param entry - * @throws IOException - */ - public void putNextEntry(TarEntry entry) throws IOException { - closeCurrentEntry(); - - byte[] header = new byte[TarConstants.HEADER_BLOCK]; - entry.writeEntryHeader( header ); - - write( header ); - - currentEntry = entry; - } - - /** - * Closes the current tar entry - * - * @throws IOException - */ - protected void closeCurrentEntry() throws IOException { - if (currentEntry != null) { - if (currentEntry.getSize() > currentFileSize) { - throw new IOException( "The current entry[" + currentEntry.getName() + "] of size[" - + currentEntry.getSize() + "] has not been fully written." ); - } - - currentEntry = null; - currentFileSize = 0; - - pad(); - } - } - - /** - * Pads the last content block - * - * @throws IOException - */ - protected void pad() throws IOException { - if (bytesWritten > 0) { - int extra = (int) ( bytesWritten % TarConstants.DATA_BLOCK ); - - if (extra > 0) { - write( new byte[TarConstants.DATA_BLOCK - extra] ); - } - } - } -} diff --git a/python-for-android/dists/kolibri/src/main/java/org/kamranzafar/jtar/TarUtils.java b/python-for-android/dists/kolibri/src/main/java/org/kamranzafar/jtar/TarUtils.java deleted file mode 100755 index 6ff5d3be..00000000 --- a/python-for-android/dists/kolibri/src/main/java/org/kamranzafar/jtar/TarUtils.java +++ /dev/null @@ -1,96 +0,0 @@ -/** - * Copyright 2012 Kamran Zafar - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - * - */ - -package org.kamranzafar.jtar; - -import java.io.File; - -/** - * @author Kamran - * - */ -public class TarUtils { - /** - * Determines the tar file size of the given folder/file path - * - * @param path - * @return - */ - public static long calculateTarSize(File path) { - return tarSize(path) + TarConstants.EOF_BLOCK; - } - - private static long tarSize(File dir) { - long size = 0; - - if (dir.isFile()) { - return entrySize(dir.length()); - } else { - File[] subFiles = dir.listFiles(); - - if (subFiles != null && subFiles.length > 0) { - for (File file : subFiles) { - if (file.isFile()) { - size += entrySize(file.length()); - } else { - size += tarSize(file); - } - } - } else { - // Empty folder header - return TarConstants.HEADER_BLOCK; - } - } - - return size; - } - - private static long entrySize(long fileSize) { - long size = 0; - size += TarConstants.HEADER_BLOCK; // Header - size += fileSize; // File size - - long extra = size % TarConstants.DATA_BLOCK; - - if (extra > 0) { - size += (TarConstants.DATA_BLOCK - extra); // pad - } - - return size; - } - - public static String trim(String s, char c) { - StringBuffer tmp = new StringBuffer(s); - for (int i = 0; i < tmp.length(); i++) { - if (tmp.charAt(i) != c) { - break; - } else { - tmp.deleteCharAt(i); - } - } - - for (int i = tmp.length() - 1; i >= 0; i--) { - if (tmp.charAt(i) != c) { - break; - } else { - tmp.deleteCharAt(i); - } - } - - return tmp.toString(); - } -} diff --git a/python-for-android/dists/kolibri/src/main/java/org/kivy/android/GenericBroadcastReceiver.java b/python-for-android/dists/kolibri/src/main/java/org/kivy/android/GenericBroadcastReceiver.java deleted file mode 100644 index 58a1c5ed..00000000 --- a/python-for-android/dists/kolibri/src/main/java/org/kivy/android/GenericBroadcastReceiver.java +++ /dev/null @@ -1,19 +0,0 @@ -package org.kivy.android; - -import android.content.BroadcastReceiver; -import android.content.Intent; -import android.content.Context; - -public class GenericBroadcastReceiver extends BroadcastReceiver { - - GenericBroadcastReceiverCallback listener; - - public GenericBroadcastReceiver(GenericBroadcastReceiverCallback listener) { - super(); - this.listener = listener; - } - - public void onReceive(Context context, Intent intent) { - this.listener.onReceive(context, intent); - } -} diff --git a/python-for-android/dists/kolibri/src/main/java/org/kivy/android/GenericBroadcastReceiverCallback.java b/python-for-android/dists/kolibri/src/main/java/org/kivy/android/GenericBroadcastReceiverCallback.java deleted file mode 100644 index 1a87c98b..00000000 --- a/python-for-android/dists/kolibri/src/main/java/org/kivy/android/GenericBroadcastReceiverCallback.java +++ /dev/null @@ -1,8 +0,0 @@ -package org.kivy.android; - -import android.content.Intent; -import android.content.Context; - -public interface GenericBroadcastReceiverCallback { - void onReceive(Context context, Intent intent); -}; diff --git a/python-for-android/dists/kolibri/src/main/java/org/kivy/android/PythonActivity.java b/python-for-android/dists/kolibri/src/main/java/org/kivy/android/PythonActivity.java deleted file mode 100644 index 33457e15..00000000 --- a/python-for-android/dists/kolibri/src/main/java/org/kivy/android/PythonActivity.java +++ /dev/null @@ -1,615 +0,0 @@ -package org.kivy.android; - -import android.os.SystemClock; - -import java.io.InputStream; -import java.io.File; -import java.io.IOException; -import java.lang.reflect.InvocationTargetException; -import java.util.Collections; -import java.util.Iterator; -import java.util.List; -import java.util.ArrayList; - -import android.util.Base64; -import android.view.ViewGroup; -import android.view.KeyEvent; -import android.app.Activity; -import android.app.AlertDialog; -import android.content.DialogInterface; -import android.content.Intent; -import android.util.Log; -import android.webkit.WebSettings; -import android.widget.Toast; -import android.os.AsyncTask; -import android.os.Bundle; -import android.os.PowerManager; -import android.content.Context; -import android.content.pm.PackageManager; -import android.widget.ImageView; -import android.graphics.Bitmap; -import android.graphics.BitmapFactory; -import android.graphics.Color; - -import android.widget.AbsoluteLayout; -import android.widget.FrameLayout; -import android.view.ViewGroup.LayoutParams; -import android.view.WindowInsets; -import android.view.View; -import android.graphics.Insets; - -import android.webkit.WebBackForwardList; -import android.webkit.WebViewClient; -import android.webkit.WebView; -import android.webkit.CookieManager; -import android.net.Uri; - -import org.learningequality.Kolibri.R; -import org.renpy.android.ResourceManager; - - -public class PythonActivity extends Activity { - // This activity is modified from a mixture of the SDLActivity and - // PythonActivity in the SDL2 bootstrap, but removing all the SDL2 - // specifics. - - private static final String TAG = "PythonActivity"; - - public static PythonActivity mActivity = null; - public static boolean mOpenExternalLinksInBrowser = false; - - /** If shared libraries (e.g. SDL or the native application) could not be loaded. */ - public static boolean mBrokenLibraries; - - protected static ViewGroup mLayout; - public static WebView mWebView; - - protected static Thread mPythonThread; - - private ResourceManager resourceManager = null; - private Bundle mMetaData = null; - private PowerManager.WakeLock mWakeLock = null; - - public String getAppRoot() { - String app_root = getFilesDir().getAbsolutePath() + "/app"; - return app_root; - } - - public String getEntryPoint(String search_dir) { - /* Get the main file (.pyc|.py) depending on if we - * have a compiled version or not. - */ - List entryPoints = new ArrayList(); - entryPoints.add("main.pyc"); // python 3 compiled files - for (String value : entryPoints) { - File mainFile = new File(search_dir + "/" + value); - if (mainFile.exists()) { - return value; - } - } - return "main.py"; - } - - public static void initialize() { - // The static nature of the singleton and Android quirkyness force us to initialize everything here - // Otherwise, when exiting the app and returning to it, these variables *keep* their pre exit values - mWebView = null; - mLayout = null; - mBrokenLibraries = false; - } - - @Override - protected void onCreate(Bundle savedInstanceState) { - Log.v(TAG, "My oncreate running"); - resourceManager = new ResourceManager(this); - super.onCreate(savedInstanceState); - - this.mActivity = this; - this.showLoadingScreen(); - new UnpackFilesTask().execute(getAppRoot()); - } - - private class UnpackFilesTask extends AsyncTask { - @Override - protected String doInBackground(String... params) { - File app_root_file = new File(params[0]); - Log.v(TAG, "Ready to unpack"); - PythonUtil.unpackAsset(mActivity, "private", app_root_file, true); - PythonUtil.unpackPyBundle(mActivity, getApplicationInfo().nativeLibraryDir + "/" + "libpybundle", app_root_file, false); - return null; - } - - @Override - protected void onPostExecute(String result) { - Log.v("Python", "Device: " + android.os.Build.DEVICE); - Log.v("Python", "Model: " + android.os.Build.MODEL); - - PythonActivity.initialize(); - - // Load shared libraries - String errorMsgBrokenLib = ""; - try { - loadLibraries(); - } catch(UnsatisfiedLinkError e) { - System.err.println(e.getMessage()); - mBrokenLibraries = true; - errorMsgBrokenLib = e.getMessage(); - } catch(Exception e) { - System.err.println(e.getMessage()); - mBrokenLibraries = true; - errorMsgBrokenLib = e.getMessage(); - } - - if (mBrokenLibraries) - { - AlertDialog.Builder dlgAlert = new AlertDialog.Builder(PythonActivity.mActivity); - dlgAlert.setMessage("An error occurred while trying to load the application libraries. Please try again and/or reinstall." - + System.getProperty("line.separator") - + System.getProperty("line.separator") - + "Error: " + errorMsgBrokenLib); - dlgAlert.setTitle("Python Error"); - dlgAlert.setPositiveButton("Exit", - new DialogInterface.OnClickListener() { - @Override - public void onClick(DialogInterface dialog,int id) { - // if this button is clicked, close current activity - PythonActivity.mActivity.finish(); - } - }); - dlgAlert.setCancelable(false); - dlgAlert.create().show(); - - return; - } - - // Set up the webview - String app_root_dir = getAppRoot(); - - mWebView = new WebView(PythonActivity.mActivity); - WebSettings webViewSettings = mWebView.getSettings(); - webViewSettings.setJavaScriptEnabled(true); - webViewSettings.setDomStorageEnabled(true); - // Follow recommended security settings from here: - // https://developer.android.com/reference/kotlin/androidx/webkit/WebViewAssetLoader - webViewSettings.setAllowFileAccessFromFileURLs(false); - webViewSettings.setAllowUniversalAccessFromFileURLs(false); - webViewSettings.setAllowFileAccess(false); - webViewSettings.setAllowContentAccess(false); - webViewSettings.setMediaPlaybackRequiresUserGesture(false); - webViewSettings.setMediaPlaybackRequiresUserGesture(false); - - String unencodedHtml = PythonActivity.mActivity.getString(R.string.loading_page_html); - String encodedHtml = Base64.encodeToString(unencodedHtml.getBytes(), Base64.NO_PADDING); - mWebView.loadData(encodedHtml, "text/html", "base64"); - mWebView.setWebViewClient(new WebViewClient() { - @Override - public boolean shouldOverrideUrlLoading(WebView view, String url) { - Uri u = Uri.parse(url); - if (mOpenExternalLinksInBrowser) { - if (!(u.getScheme().equals("file") || u.getHost().equals("127.0.0.1"))) { - Intent i = new Intent(Intent.ACTION_VIEW, u); - startActivity(i); - return true; - } - } - return false; - } - - @Override - public void onPageFinished(WebView view, String url) { - CookieManager.getInstance().setAcceptCookie(true); - CookieManager.getInstance().acceptCookie(); - CookieManager.getInstance().flush(); - - } - }); - - // Handle edge-to-edge insets only on Android 15+ (API 35+) where edge-to-edge is enforced - if (android.os.Build.VERSION.SDK_INT >= 35) { - // Use FrameLayout for Android 15+ to properly support margins for edge-to-edge - mLayout = new FrameLayout(PythonActivity.mActivity); - - // Set WebView with FrameLayout params that support margins - FrameLayout.LayoutParams webViewParams = new FrameLayout.LayoutParams( - FrameLayout.LayoutParams.MATCH_PARENT, - FrameLayout.LayoutParams.MATCH_PARENT - ); - mWebView.setLayoutParams(webViewParams); - mLayout.addView(mWebView); - - mLayout.setOnApplyWindowInsetsListener(new View.OnApplyWindowInsetsListener() { - @Override - public WindowInsets onApplyWindowInsets(View v, WindowInsets insets) { - // Get system bar insets using the Android 11+ API - android.graphics.Insets systemBarsInsets = insets.getInsets(WindowInsets.Type.systemBars()); - - // Apply margins to the WebView to avoid system bars - FrameLayout.LayoutParams params = (FrameLayout.LayoutParams) mWebView.getLayoutParams(); - params.leftMargin = systemBarsInsets.left; - params.topMargin = systemBarsInsets.top; - params.rightMargin = systemBarsInsets.right; - params.bottomMargin = systemBarsInsets.bottom; - mWebView.setLayoutParams(params); - - // Return the insets unchanged to allow other views to also handle them - return insets; - } - }); - // Enable edge-to-edge but handle insets properly - mLayout.setFitsSystemWindows(false); - } else { - // For older Android versions, use the original AbsoluteLayout setup - mLayout = new AbsoluteLayout(PythonActivity.mActivity); - mWebView.setLayoutParams(new LayoutParams(LayoutParams.FILL_PARENT, LayoutParams.FILL_PARENT)); - mLayout.addView(mWebView); - } - - setContentView(mLayout); - - String mFilesDirectory = mActivity.getFilesDir().getAbsolutePath(); - String entry_point = getEntryPoint(app_root_dir); - - Log.v(TAG, "Setting env vars for start.c and Python to use"); - PythonActivity.nativeSetenv("ANDROID_ENTRYPOINT", entry_point); - PythonActivity.nativeSetenv("ANDROID_ARGUMENT", app_root_dir); - PythonActivity.nativeSetenv("ANDROID_APP_PATH", app_root_dir); - PythonActivity.nativeSetenv("ANDROID_PRIVATE", mFilesDirectory); - PythonActivity.nativeSetenv("ANDROID_UNPACK", app_root_dir); - PythonActivity.nativeSetenv("PYTHONHOME", app_root_dir); - PythonActivity.nativeSetenv("PYTHONPATH", app_root_dir + ":" + app_root_dir + "/lib"); - PythonActivity.nativeSetenv("PYTHONOPTIMIZE", "2"); - - try { - Log.v(TAG, "Access to our meta-data..."); - mActivity.mMetaData = mActivity.getPackageManager().getApplicationInfo( - mActivity.getPackageName(), PackageManager.GET_META_DATA).metaData; - - PowerManager pm = (PowerManager) mActivity.getSystemService(Context.POWER_SERVICE); - if ( mActivity.mMetaData.getInt("wakelock") == 1 ) { - mActivity.mWakeLock = pm.newWakeLock(PowerManager.SCREEN_BRIGHT_WAKE_LOCK, "Screen On"); - mActivity.mWakeLock.acquire(); - } - } catch (PackageManager.NameNotFoundException e) { - } - - final Thread pythonThread = new Thread(new PythonMain(), "PythonThread"); - PythonActivity.mPythonThread = pythonThread; - pythonThread.start(); - } - } - - @Override - public void onDestroy() { - Log.i("Destroy", "end of app"); - super.onDestroy(); - - // make sure all child threads (python_thread) are stopped - android.os.Process.killProcess(android.os.Process.myPid()); - } - - public void loadLibraries() { - PythonLoader.doLoad(this); - } - - public static void loadUrl(String url) { - class LoadUrl implements Runnable { - private String mUrl; - - public LoadUrl(String url) { - mUrl = url; - } - - public void run() { - mWebView.loadUrl(mUrl); - } - } - - Log.i(TAG, "Opening URL: " + url); - mActivity.runOnUiThread(new LoadUrl(url)); - } - - public static void enableZoom() { - mActivity.runOnUiThread(new Runnable() { - @Override - public void run() { - mWebView.getSettings().setBuiltInZoomControls(true); - mWebView.getSettings().setDisplayZoomControls(false); - } - }); - } - - public static ViewGroup getLayout() { - return mLayout; - } - - long lastBackClick = 0; - @Override - public boolean onKeyDown(int keyCode, KeyEvent event) { - // Check if the key event was the Back button - if (keyCode == KeyEvent.KEYCODE_BACK) { - // Go back if there is web page history behind, - // but not to the start preloader - WebBackForwardList webViewBackForwardList = mWebView.copyBackForwardList(); - if (webViewBackForwardList.getCurrentIndex() > 1) { - mWebView.goBack(); - return true; - } - - // If there's no web page history, bubble up to the default - // system behavior (probably exit the activity) - if (SystemClock.elapsedRealtime() - lastBackClick > 2000){ - lastBackClick = SystemClock.elapsedRealtime(); - Toast.makeText(this, "Tap again to close the app", Toast.LENGTH_LONG).show(); - return true; - } - - lastBackClick = SystemClock.elapsedRealtime(); - } - - return super.onKeyDown(keyCode, event); - } - - // loading screen implementation - public static ImageView mImageView = null; - public void removeLoadingScreen() { - runOnUiThread(new Runnable() { - public void run() { - if (PythonActivity.mImageView != null && - PythonActivity.mImageView.getParent() != null) { - ((ViewGroup)PythonActivity.mImageView.getParent()).removeView( - PythonActivity.mImageView); - PythonActivity.mImageView = null; - } - } - }); - } - - protected void showLoadingScreen() { - // load the bitmap - // 1. if the image is valid and we don't have layout yet, assign this bitmap - // as main view. - // 2. if we have a layout, just set it in the layout. - // 3. If we have an mImageView already, then do nothing because it will have - // already been made the content view or added to the layout. - - if (mImageView == null) { - int presplashId = this.resourceManager.getIdentifier("presplash", "drawable"); - InputStream is = this.getResources().openRawResource(presplashId); - Bitmap bitmap = null; - try { - bitmap = BitmapFactory.decodeStream(is); - } finally { - try { - is.close(); - } catch (IOException e) {}; - } - - mImageView = new ImageView(this); - mImageView.setImageBitmap(bitmap); - - /* - * Set the presplash loading screen background color - * https://developer.android.com/reference/android/graphics/Color.html - * Parse the color string, and return the corresponding color-int. - * If the string cannot be parsed, throws an IllegalArgumentException exception. - * Supported formats are: #RRGGBB #AARRGGBB or one of the following names: - * 'red', 'blue', 'green', 'black', 'white', 'gray', 'cyan', 'magenta', 'yellow', - * 'lightgray', 'darkgray', 'grey', 'lightgrey', 'darkgrey', 'aqua', 'fuchsia', - * 'lime', 'maroon', 'navy', 'olive', 'purple', 'silver', 'teal'. - */ - try { - mImageView.setBackgroundColor(Color.parseColor("#FFCB00")); - } catch (IllegalArgumentException e) {} - mImageView.setLayoutParams(new ViewGroup.LayoutParams( - ViewGroup.LayoutParams.FILL_PARENT, - ViewGroup.LayoutParams.FILL_PARENT)); - mImageView.setScaleType(ImageView.ScaleType.FIT_CENTER); - - } - - if (mLayout == null) { - setContentView(mImageView); - } else if (PythonActivity.mImageView.getParent() == null){ - mLayout.addView(mImageView); - } - } - - //---------------------------------------------------------------------------- - // Listener interface for onNewIntent - // - - public interface NewIntentListener { - void onNewIntent(Intent intent); - } - - private List newIntentListeners = null; - - public void registerNewIntentListener(NewIntentListener listener) { - if ( this.newIntentListeners == null ) - this.newIntentListeners = Collections.synchronizedList(new ArrayList()); - this.newIntentListeners.add(listener); - } - - public void unregisterNewIntentListener(NewIntentListener listener) { - if ( this.newIntentListeners == null ) - return; - this.newIntentListeners.remove(listener); - } - - @Override - protected void onNewIntent(Intent intent) { - if ( this.newIntentListeners == null ) - return; - this.onResume(); - synchronized ( this.newIntentListeners ) { - Iterator iterator = this.newIntentListeners.iterator(); - while ( iterator.hasNext() ) { - (iterator.next()).onNewIntent(intent); - } - } - } - - //---------------------------------------------------------------------------- - // Listener interface for onActivityResult - // - - public interface ActivityResultListener { - void onActivityResult(int requestCode, int resultCode, Intent data); - } - - private List activityResultListeners = null; - - public void registerActivityResultListener(ActivityResultListener listener) { - if ( this.activityResultListeners == null ) - this.activityResultListeners = Collections.synchronizedList(new ArrayList()); - this.activityResultListeners.add(listener); - } - - public void unregisterActivityResultListener(ActivityResultListener listener) { - if ( this.activityResultListeners == null ) - return; - this.activityResultListeners.remove(listener); - } - - @Override - protected void onActivityResult(int requestCode, int resultCode, Intent intent) { - if ( this.activityResultListeners == null ) - return; - this.onResume(); - synchronized ( this.activityResultListeners ) { - Iterator iterator = this.activityResultListeners.iterator(); - while ( iterator.hasNext() ) - (iterator.next()).onActivityResult(requestCode, resultCode, intent); - } - } - - public static void start_service( - String serviceTitle, - String serviceDescription, - String pythonServiceArgument - ) { - _do_start_service( - serviceTitle, serviceDescription, pythonServiceArgument, true - ); - } - - public static void start_service_not_as_foreground( - String serviceTitle, - String serviceDescription, - String pythonServiceArgument - ) { - _do_start_service( - serviceTitle, serviceDescription, pythonServiceArgument, false - ); - } - - public static void _do_start_service( - String serviceTitle, - String serviceDescription, - String pythonServiceArgument, - boolean showForegroundNotification - ) { - Intent serviceIntent = new Intent(PythonActivity.mActivity, PythonService.class); - String argument = PythonActivity.mActivity.getFilesDir().getAbsolutePath(); - String app_root_dir = PythonActivity.mActivity.getAppRoot(); - String entry_point = PythonActivity.mActivity.getEntryPoint(app_root_dir + "/service"); - serviceIntent.putExtra("androidPrivate", argument); - serviceIntent.putExtra("androidArgument", app_root_dir); - serviceIntent.putExtra("serviceEntrypoint", "service/" + entry_point); - serviceIntent.putExtra("pythonName", "python"); - serviceIntent.putExtra("pythonHome", app_root_dir); - serviceIntent.putExtra("pythonPath", app_root_dir + ":" + app_root_dir + "/lib"); - serviceIntent.putExtra("serviceStartAsForeground", - (showForegroundNotification ? "true" : "false") - ); - serviceIntent.putExtra("serviceTitle", serviceTitle); - serviceIntent.putExtra("serviceDescription", serviceDescription); - serviceIntent.putExtra("pythonServiceArgument", pythonServiceArgument); - PythonActivity.mActivity.startService(serviceIntent); - } - - public static void stop_service() { - Intent serviceIntent = new Intent(PythonActivity.mActivity, PythonService.class); - PythonActivity.mActivity.stopService(serviceIntent); - } - - - public static native void nativeSetenv(String name, String value); - public static native int nativeInit(Object arguments); - - - /** - * Used by android.permissions p4a module to register a call back after - * requesting runtime permissions - **/ - public interface PermissionsCallback { - void onRequestPermissionsResult(int requestCode, String[] permissions, int[] grantResults); - } - - private PermissionsCallback permissionCallback; - private boolean havePermissionsCallback = false; - - public void addPermissionsCallback(PermissionsCallback callback) { - permissionCallback = callback; - havePermissionsCallback = true; - Log.v(TAG, "addPermissionsCallback(): Added callback for onRequestPermissionsResult"); - } - - @Override - public void onRequestPermissionsResult(int requestCode, String[] permissions, int[] grantResults) { - Log.v(TAG, "onRequestPermissionsResult()"); - if (havePermissionsCallback) { - Log.v(TAG, "onRequestPermissionsResult passed to callback"); - permissionCallback.onRequestPermissionsResult(requestCode, permissions, grantResults); - } - super.onRequestPermissionsResult(requestCode, permissions, grantResults); - } - - /** - * Used by android.permissions p4a module to check a permission - **/ - public boolean checkCurrentPermission(String permission) { - if (android.os.Build.VERSION.SDK_INT < 23) - return true; - - try { - java.lang.reflect.Method methodCheckPermission = - Activity.class.getMethod("checkSelfPermission", String.class); - Object resultObj = methodCheckPermission.invoke(this, permission); - int result = Integer.parseInt(resultObj.toString()); - if (result == PackageManager.PERMISSION_GRANTED) - return true; - } catch (IllegalAccessException | NoSuchMethodException | - InvocationTargetException e) { - } - return false; - } - - /** - * Used by android.permissions p4a module to request runtime permissions - **/ - public void requestPermissionsWithRequestCode(String[] permissions, int requestCode) { - if (android.os.Build.VERSION.SDK_INT < 23) - return; - try { - java.lang.reflect.Method methodRequestPermission = - Activity.class.getMethod("requestPermissions", - String[].class, int.class); - methodRequestPermission.invoke(this, permissions, requestCode); - } catch (IllegalAccessException | NoSuchMethodException | - InvocationTargetException e) { - } - } - - public void requestPermissions(String[] permissions) { - requestPermissionsWithRequestCode(permissions, 1); - } -} - - -class PythonMain implements Runnable { - @Override - public void run() { - PythonActivity.nativeInit(new String[0]); - } -} 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 deleted file mode 100644 index 6ed4cb2f..00000000 --- a/python-for-android/dists/kolibri/src/main/java/org/kivy/android/PythonContext.java +++ /dev/null @@ -1,33 +0,0 @@ -package org.kivy.android; - -import android.content.Context; - -public class PythonContext { - public static PythonContext mInstance; - - private final Context context; - - private PythonContext(Context context) { - this.context = context; - } - - public static PythonContext getInstance(Context context) { - if (mInstance == null) { - synchronized (PythonContext.class) { - if (mInstance == null) { - mInstance = new PythonContext( - context.getApplicationContext() - ); - } - } - } - return PythonContext.mInstance; - } - - public static Context get() { - if (PythonContext.mInstance == null) { - return null; - } - return PythonContext.mInstance.context; - } -} diff --git a/python-for-android/dists/kolibri/src/main/java/org/kivy/android/PythonLoader.java b/python-for-android/dists/kolibri/src/main/java/org/kivy/android/PythonLoader.java deleted file mode 100644 index 578ce567..00000000 --- a/python-for-android/dists/kolibri/src/main/java/org/kivy/android/PythonLoader.java +++ /dev/null @@ -1,44 +0,0 @@ -package org.kivy.android; - -import android.content.Context; - -import java.io.File; -import java.util.concurrent.atomic.AtomicBoolean; - -public class PythonLoader { - protected static PythonLoader mInstance; - - private final File src; - private final AtomicBoolean isLoaded = new AtomicBoolean(false); - - private PythonLoader(File src) { - this.src = src; - } - - public void load() { - synchronized (isLoaded) { - if (isLoaded.get()) { - return; - } - PythonUtil.loadLibraries(src); - isLoaded.set(true); - } - } - - public static PythonLoader getInstance(Context context) { - if (mInstance == null) { - synchronized (PythonLoader.class) { - if (mInstance == null) { - mInstance = new PythonLoader( - new File(context.getApplicationInfo().nativeLibraryDir) - ); - } - } - } - return PythonLoader.mInstance; - } - - public static void doLoad(Context context) { - PythonLoader.getInstance(context).load(); - } -} diff --git a/python-for-android/dists/kolibri/src/main/java/org/kivy/android/PythonProvider.java b/python-for-android/dists/kolibri/src/main/java/org/kivy/android/PythonProvider.java deleted file mode 100644 index bb77d650..00000000 --- a/python-for-android/dists/kolibri/src/main/java/org/kivy/android/PythonProvider.java +++ /dev/null @@ -1,46 +0,0 @@ -package org.kivy.android; - -import android.content.Context; - -/** - * This class is used to provide the Context to the python-side in a way that minimizes us - * potentially creating memory leaks by holding a static reference to the context. It implements - * AutoCloseable so that it can be used in a try-with-resources block and automatically release - * the static instance and the instance's context. - */ -public class PythonProvider implements AutoCloseable { - private static final ThreadLocal localInstance = new ThreadLocal<>(); - private final Context context; - - public PythonProvider(Context context) { - this.context = context; - localInstance.set(this); - } - - public Context getContext() { - return context; - } - - - public void close() { - localInstance.remove(); - } - - public static PythonProvider create(Context context) { - if (isActive()) { - throw new RuntimeException("PythonProviders cannot be nested"); - } - return new PythonProvider(context); - } - - public static PythonProvider get() { - if (!isActive()) { - throw new RuntimeException("PythonProvider not initialized"); - } - return PythonProvider.localInstance.get(); - } - - public static boolean isActive() { - return localInstance.get() != null; - } -} diff --git a/python-for-android/dists/kolibri/src/main/java/org/kivy/android/PythonService.java b/python-for-android/dists/kolibri/src/main/java/org/kivy/android/PythonService.java deleted file mode 100644 index 4b958698..00000000 --- a/python-for-android/dists/kolibri/src/main/java/org/kivy/android/PythonService.java +++ /dev/null @@ -1,207 +0,0 @@ -package org.kivy.android; - -import android.os.Build; -import java.lang.reflect.Method; -import java.lang.reflect.InvocationTargetException; -import android.app.Service; -import android.os.IBinder; -import android.os.Bundle; -import android.content.Intent; -import android.content.Context; -import android.util.Log; -import android.app.Notification; -import android.app.PendingIntent; -import android.os.Process; -import java.io.File; - -//imports for channel definition -import android.app.NotificationManager; -import android.app.NotificationChannel; -import android.graphics.Color; - -public class PythonService extends Service implements Runnable { - - // Thread for Python code - private Thread pythonThread = null; - - // Python environment variables - private String androidPrivate; - private String androidArgument; - private String pythonName; - private String pythonHome; - private String pythonPath; - private String serviceEntrypoint; - // Argument to pass to Python code, - private String pythonServiceArgument; - - - public static PythonService mService = null; - private Intent startIntent = null; - - private boolean autoRestartService = false; - - public void setAutoRestartService(boolean restart) { - autoRestartService = restart; - } - - public int startType() { - return START_NOT_STICKY; - } - - @Override - public IBinder onBind(Intent arg0) { - return null; - } - - @Override - public void onCreate() { - super.onCreate(); - } - - @Override - public int onStartCommand(Intent intent, int flags, int startId) { - if (pythonThread != null) { - Log.v("python service", "service exists, do not start again"); - return startType(); - } - //intent is null if OS restarts a STICKY service - if (intent == null) { - Context context = getApplicationContext(); - intent = getThisDefaultIntent(context, ""); - } - - startIntent = intent; - Bundle extras = intent.getExtras(); - androidPrivate = extras.getString("androidPrivate"); - androidArgument = extras.getString("androidArgument"); - serviceEntrypoint = extras.getString("serviceEntrypoint"); - pythonName = extras.getString("pythonName"); - pythonHome = extras.getString("pythonHome"); - pythonPath = extras.getString("pythonPath"); - boolean serviceStartAsForeground = ( - extras.getString("serviceStartAsForeground").equals("true") - ); - pythonServiceArgument = extras.getString("pythonServiceArgument"); - pythonThread = new Thread(this); - pythonThread.start(); - - if (serviceStartAsForeground) { - doStartForeground(extras); - } - - return startType(); - } - - protected int getServiceId() { - return 1; - } - - protected Intent getThisDefaultIntent(Context ctx, String pythonServiceArgument) { - return null; - } - - protected void doStartForeground(Bundle extras) { - String serviceTitle = extras.getString("serviceTitle"); - String smallIconName = extras.getString("smallIconName"); - String contentTitle = extras.getString("contentTitle"); - String contentText = extras.getString("contentText"); - Notification notification; - Context context = getApplicationContext(); - Intent contextIntent = new Intent(context, PythonActivity.class); - PendingIntent pIntent = PendingIntent.getActivity(context, 0, contextIntent, - PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT); - - // Unspecified icon uses default. - int smallIconId = context.getApplicationInfo().icon; - if (!smallIconName.equals("")){ - int resId = getResources().getIdentifier(smallIconName, "mipmap", - getPackageName()); - if (resId ==0) { - resId = getResources().getIdentifier(smallIconName, "drawable", - getPackageName()); - } - if (resId !=0) { - smallIconId = resId; - } - } - - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { - // This constructor is deprecated - notification = new Notification( - smallIconId, serviceTitle, System.currentTimeMillis()); - try { - // prevent using NotificationCompat, this saves 100kb on apk - Method func = notification.getClass().getMethod( - "setLatestEventInfo", Context.class, CharSequence.class, - CharSequence.class, PendingIntent.class); - func.invoke(notification, context, contentTitle, contentText, pIntent); - } catch (NoSuchMethodException | IllegalAccessException | - IllegalArgumentException | InvocationTargetException e) { - } - } else { - // for android 8+ we need to create our own channel - // https://stackoverflow.com/questions/47531742/startforeground-fail-after-upgrade-to-android-8-1 - String NOTIFICATION_CHANNEL_ID = "org.kivy.p4a" + getServiceId(); - String channelName = "Background Service" + getServiceId(); - NotificationChannel chan = new NotificationChannel(NOTIFICATION_CHANNEL_ID, channelName, - NotificationManager.IMPORTANCE_NONE); - - chan.setLightColor(Color.BLUE); - chan.setLockscreenVisibility(Notification.VISIBILITY_PRIVATE); - NotificationManager manager = (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE); - manager.createNotificationChannel(chan); - - Notification.Builder builder = new Notification.Builder(context, NOTIFICATION_CHANNEL_ID); - builder.setContentTitle(contentTitle); - builder.setContentText(contentText); - builder.setContentIntent(pIntent); - builder.setSmallIcon(smallIconId); - notification = builder.build(); - } - startForeground(getServiceId(), notification); - } - - @Override - public void onDestroy() { - super.onDestroy(); - pythonThread = null; - if (autoRestartService && startIntent != null) { - Log.v("python service", "service restart requested"); - startService(startIntent); - } - Process.killProcess(Process.myPid()); - } - - /** - * Stops the task gracefully when killed. - * Calling stopSelf() will trigger a onDestroy() call from the system. - */ - @Override - public void onTaskRemoved(Intent rootIntent) { - super.onTaskRemoved(rootIntent); - //sticky service runtime/restart is managed by the OS. leave it running when app is closed - if (startType() != START_STICKY) { - stopSelf(); - } - } - - @Override - public void run(){ - PythonUtil.loadLibraries( - new File(getApplicationInfo().nativeLibraryDir)); - this.mService = this; - nativeStart( - androidPrivate, androidArgument, - serviceEntrypoint, pythonName, - pythonHome, pythonPath, - pythonServiceArgument); - stopSelf(); - } - - // Native part - public static native int nativeStart( - String androidPrivate, String androidArgument, - String serviceEntrypoint, String pythonName, - String pythonHome, String pythonPath, - String pythonServiceArgument); -} diff --git a/python-for-android/dists/kolibri/src/main/java/org/kivy/android/PythonUtil.java b/python-for-android/dists/kolibri/src/main/java/org/kivy/android/PythonUtil.java deleted file mode 100644 index 44a6822d..00000000 --- a/python-for-android/dists/kolibri/src/main/java/org/kivy/android/PythonUtil.java +++ /dev/null @@ -1,267 +0,0 @@ -package org.kivy.android; - -import java.io.InputStream; -import java.io.FileInputStream; -import java.io.FileOutputStream; -import java.io.File; - -import android.app.Activity; -import android.content.Context; -import android.content.res.Resources; -import android.util.Log; -import android.widget.Toast; - -import java.util.ArrayList; -import java.util.regex.Pattern; - -import org.learningequality.Kolibri.BuildConfig; -import org.renpy.android.AssetExtract; - -public class PythonUtil { - private static final String TAG = "pythonutil"; - - // We read this directly from the VERSION_CODE, - // so that any upgrade of the app causes the Python - // code to be extracted again. - private static final String PrivateVersion = BuildConfig.VERSION_CODE; - - protected static void addLibraryIfExists(ArrayList libsList, String pattern, File libsDir) { - // pattern should be the name of the lib file, without the - // preceding "lib" or suffix ".so", for instance "ssl.*" will - // match files of the form "libssl.*.so". - File [] files = libsDir.listFiles(); - - pattern = "lib" + pattern + "\\.so"; - Pattern p = Pattern.compile(pattern); - for (int i = 0; i < files.length; ++i) { - File file = files[i]; - String name = file.getName(); - Log.v(TAG, "Checking pattern " + pattern + " against " + name); - if (p.matcher(name).matches()) { - Log.v(TAG, "Pattern " + pattern + " matched file " + name); - libsList.add(name.substring(3, name.length() - 3)); - } - } - } - - protected static ArrayList getLibraries(File libsDir) { - ArrayList libsList = new ArrayList(); - addLibraryIfExists(libsList, "sqlite3", libsDir); - addLibraryIfExists(libsList, "ffi", libsDir); - addLibraryIfExists(libsList, "png16", libsDir); - addLibraryIfExists(libsList, "ssl.*", libsDir); - addLibraryIfExists(libsList, "crypto.*", libsDir); - addLibraryIfExists(libsList, "SDL2", libsDir); - addLibraryIfExists(libsList, "SDL2_image", libsDir); - addLibraryIfExists(libsList, "SDL2_mixer", libsDir); - addLibraryIfExists(libsList, "SDL2_ttf", libsDir); - libsList.add("python3.9"); - libsList.add("main"); - return libsList; - } - - public static void loadLibraries(File libsDir) { - boolean foundPython = false; - - for (String lib : getLibraries(libsDir)) { - Log.v(TAG, "Loading library: " + lib); - try { - System.loadLibrary(lib); - if (lib.startsWith("python")) { - foundPython = true; - } - } catch(UnsatisfiedLinkError e) { - // If this is the last possible libpython - // load, and it has failed, give a more - // general error - Log.v(TAG, "Library loading error: " + e.getMessage()); - if (lib.startsWith("python3.9") && !foundPython) { - throw new RuntimeException("Could not load any libpythonXXX.so"); - } else if (lib.startsWith("python")) { - continue; - } else { - Log.v(TAG, "An UnsatisfiedLinkError occurred loading " + lib); - throw e; - } - } - } - - Log.v(TAG, "Loaded everything!"); - } - - public static String getAppRoot(Context ctx) { - String appRoot = ctx.getFilesDir().getAbsolutePath() + "/app"; - return appRoot; - } - - public static String getResourceString(Context ctx, String name) { - // Taken from org.renpy.android.ResourceManager - Resources res = ctx.getResources(); - int id = res.getIdentifier(name, "string", ctx.getPackageName()); - return res.getString(id); - } - - /** - * Show an error using a toast. (Only makes sense from non-UI threads.) - */ - protected static void toastError(final Activity activity, final String msg) { - activity.runOnUiThread(new Runnable () { - public void run() { - Toast.makeText(activity, msg, Toast.LENGTH_LONG).show(); - } - }); - - // Wait to show the error. - synchronized (activity) { - try { - activity.wait(1000); - } catch (InterruptedException e) { - } - } - } - - protected static void recursiveDelete(File f) { - if (f.isDirectory()) { - for (File r : f.listFiles()) { - recursiveDelete(r); - } - } - f.delete(); - } - - public static void unpackAsset( - Context ctx, - final String resource, - File target, - boolean cleanup_on_version_update) { - - // We only have version information for the private package - // and this should only ever be called for the "private" package - if (resource != "private") { - Log.v(TAG, "Cannot unpack " + resource + " " + target.getName()); - return; - } - - Log.v(TAG, "Unpacking " + resource + " " + target.getName()); - - // The version of data in memory and on disk. - String dataVersion = PrivateVersion; - String diskVersion = null; - - Log.v(TAG, "Data version is " + dataVersion); - - // If no version, no unpacking is necessary. - if (dataVersion == null) { - return; - } - - // Check the current disk version, if any. - String filesDir = target.getAbsolutePath(); - String diskVersionFn = filesDir + "/" + resource + ".version"; - - try { - byte buf[] = new byte[64]; - InputStream is = new FileInputStream(diskVersionFn); - int len = is.read(buf); - diskVersion = new String(buf, 0, len); - is.close(); - } catch (Exception e) { - diskVersion = ""; - } - - // If the disk data is out of date, extract it and write the version file. - if (! dataVersion.equals(diskVersion)) { - Log.v(TAG, "Extracting " + resource + " assets."); - - if (cleanup_on_version_update) { - recursiveDelete(target); - } - target.mkdirs(); - - AssetExtract ae = new AssetExtract(ctx); - if (!ae.extractTar(resource + ".tar", target.getAbsolutePath(), "private")) { - String msg = "Could not extract " + resource + " data."; - if (ctx instanceof Activity) { - toastError((Activity)ctx, msg); - } else { - Log.v(TAG, msg); - } - } - - try { - // Write .nomedia. - new File(target, ".nomedia").createNewFile(); - - // Write version file. - FileOutputStream os = new FileOutputStream(diskVersionFn); - os.write(dataVersion.getBytes()); - os.close(); - } catch (Exception e) { - Log.w(TAG, e); - } - } - } - - public static void unpackPyBundle( - Context ctx, - final String resource, - File target, - boolean cleanup_on_version_update) { - - Log.v(TAG, "Unpacking " + resource + " " + target.getName()); - - // The version of data in memory and on disk. - String dataVersion = PrivateVersion; - String diskVersion = null; - - Log.v(TAG, "Data version is " + dataVersion); - - // If no version, no unpacking is necessary. - if (dataVersion == null) { - return; - } - - // Check the current disk version, if any. - String filesDir = target.getAbsolutePath(); - String diskVersionFn = filesDir + "/" + "libpybundle" + ".version"; - - try { - byte buf[] = new byte[64]; - InputStream is = new FileInputStream(diskVersionFn); - int len = is.read(buf); - diskVersion = new String(buf, 0, len); - is.close(); - } catch (Exception e) { - diskVersion = ""; - } - - if (! dataVersion.equals(diskVersion)) { - // If the disk data is out of date, extract it and write the version file. - Log.v(TAG, "Extracting " + resource + " assets."); - - if (cleanup_on_version_update) { - recursiveDelete(target); - } - target.mkdirs(); - - AssetExtract ae = new AssetExtract(ctx); - if (!ae.extractTar(resource + ".so", target.getAbsolutePath(), "pybundle")) { - String msg = "Could not extract " + resource + " data."; - if (ctx instanceof Activity) { - toastError((Activity)ctx, msg); - } else { - Log.v(TAG, msg); - } - } - - try { - // Write version file. - FileOutputStream os = new FileOutputStream(diskVersionFn); - os.write(dataVersion.getBytes()); - os.close(); - } catch (Exception e) { - Log.w(TAG, e); - } - } - } -} 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 deleted file mode 100644 index 3521ad3e..00000000 --- a/python-for-android/dists/kolibri/src/main/java/org/kivy/android/PythonWorker.java +++ /dev/null @@ -1,81 +0,0 @@ -package org.kivy.android; - -import android.content.Context; -import android.os.Process; -import android.util.Log; - -import androidx.annotation.NonNull; - -import org.learningequality.Kolibri.sqlite.JobStorage; -import org.learningequality.sqlite.query.UpdateQuery; - -/** - * Ideally this would be called `PythonWorkerImpl` but the name is used in the native code. - */ -public class PythonWorker { - private static final String TAG = "PythonWorkerImpl"; - // Python environment variables - private final String pythonName; - private final String workerEntrypoint; - private final String androidPrivate; - private final String androidArgument; - private final String pythonHome; - private final String pythonPath; - - - public PythonWorker(@NonNull Context context, String pythonName, String workerEntrypoint) { - PythonLoader.doLoad(context); - this.pythonName = pythonName; - this.workerEntrypoint = workerEntrypoint; - String appRoot = PythonUtil.getAppRoot(context); - androidPrivate = appRoot; - androidArgument = appRoot; - pythonHome = appRoot; - pythonPath = appRoot + ":" + appRoot + "/lib"; - } - - // Native part - public static native int nativeStart( - String androidPrivate, String androidArgument, - String workerEntrypoint, String pythonName, - String pythonHome, String pythonPath, - String pythonServiceArgument - ); - - public static native int tearDownPython(); - - public boolean execute(String id, String arg) { - Log.d(TAG, id + " Running with python worker argument: " + arg); - - String serializedArg = String.join( - ",", - id, - arg, - Integer.toString(Process.myPid()), - Long.toString(Thread.currentThread().getId()) - ); - - int res; - try { - res = nativeStart( - androidPrivate, androidArgument, - workerEntrypoint, pythonName, - pythonHome, pythonPath, - serializedArg - ); - Log.d(TAG, id + " Finished executing python work: " + res); - if (res == 0) { - // If the result is 0, execution was successful - return true; - } else { - // For any result other than 0, log and treat as a failure - Log.e(TAG, "Python work execution failed with result code: " + res); - return false; - } - } catch (Exception e) { - // Catch and log any exceptions, treating them as execution failures - Log.e(TAG, "Error executing python work", e); - return false; - } - } -} diff --git a/python-for-android/dists/kolibri/src/main/java/org/learningequality/FullScreen.java b/python-for-android/dists/kolibri/src/main/java/org/learningequality/FullScreen.java deleted file mode 100644 index 1807384f..00000000 --- a/python-for-android/dists/kolibri/src/main/java/org/learningequality/FullScreen.java +++ /dev/null @@ -1,99 +0,0 @@ -package org.learningequality; - -import android.graphics.Bitmap; -import android.graphics.BitmapFactory; -import android.view.View; -import android.webkit.CookieManager; -import android.webkit.WebChromeClient; -import android.webkit.WebSettings; -import android.webkit.WebView; -import android.webkit.WebViewClient; -import android.widget.FrameLayout; - -import org.kivy.android.PythonActivity; - -public class FullScreen { - public PythonActivity mActivity; - public WebView mWebView; - public MyChrome mChrome; - - public FullScreen(PythonActivity activity) { - mActivity = activity; - mWebView = (WebView) activity.getLayout().getChildAt(0); - mChrome = new MyChrome(activity); - } - - public static void configureWebview(PythonActivity activity) { - FullScreen fs = new FullScreen(activity); - fs.configure(); - } - - // Configure the WebView to allow fullscreen based on: - // https://stackoverflow.com/questions/15768837/playing-html5-video-on-fullscreen-in-android-webview/56186877#56186877 - public void configure() { - mWebView.setWebViewClient(new WebViewClient() { - - @Override - public void onPageFinished(WebView view, String url) { - CookieManager.getInstance().setAcceptCookie(true); - CookieManager.getInstance().acceptCookie(); - CookieManager.getInstance().flush(); - - } - - - }); - mWebView.setWebChromeClient(mChrome); - WebSettings webSettings = mWebView.getSettings(); - webSettings.setJavaScriptEnabled(true); - webSettings.setAllowFileAccess(true); - webSettings.setCacheMode(WebSettings.LOAD_DEFAULT); - } - - private class MyChrome extends WebChromeClient { - - private View mCustomView; - private WebChromeClient.CustomViewCallback mCustomViewCallback; - protected FrameLayout mFullscreenContainer; - private int mOriginalOrientation; - private int mOriginalSystemUiVisibility; - public PythonActivity mActivity = null; - - MyChrome(PythonActivity activity) { - mActivity = activity; - } - - public Bitmap getDefaultVideoPoster() - { - if (mCustomView == null) { - return null; - } - return BitmapFactory.decodeResource(mActivity.getApplicationContext().getResources(), 2130837573); - } - - public void onHideCustomView() - { - ((FrameLayout)mActivity.getWindow().getDecorView()).removeView(this.mCustomView); - this.mCustomView = null; - mActivity.getWindow().getDecorView().setSystemUiVisibility(this.mOriginalSystemUiVisibility); - mActivity.setRequestedOrientation(this.mOriginalOrientation); - this.mCustomViewCallback.onCustomViewHidden(); - this.mCustomViewCallback = null; - } - - public void onShowCustomView(View paramView, WebChromeClient.CustomViewCallback paramCustomViewCallback) - { - if (this.mCustomView != null) - { - onHideCustomView(); - return; - } - this.mCustomView = paramView; - this.mOriginalSystemUiVisibility = mActivity.getWindow().getDecorView().getSystemUiVisibility(); - this.mOriginalOrientation = mActivity.getRequestedOrientation(); - this.mCustomViewCallback = paramCustomViewCallback; - ((FrameLayout)mActivity.getWindow().getDecorView()).addView(this.mCustomView, new FrameLayout.LayoutParams(-1, -1)); - mActivity.getWindow().getDecorView().setSystemUiVisibility(3846 | View.SYSTEM_UI_FLAG_LAYOUT_STABLE); - } - } -} diff --git a/python-for-android/dists/kolibri/src/main/java/org/learningequality/FuturesUtil.java b/python-for-android/dists/kolibri/src/main/java/org/learningequality/FuturesUtil.java deleted file mode 100644 index 9e587f0a..00000000 --- a/python-for-android/dists/kolibri/src/main/java/org/learningequality/FuturesUtil.java +++ /dev/null @@ -1,39 +0,0 @@ -package org.learningequality; - -import android.util.Log; - -import com.google.common.util.concurrent.ListenableFuture; - -import java.util.concurrent.ExecutionException; -import java.util.concurrent.Executor; - -import java9.util.concurrent.CompletableFuture; - -public class FuturesUtil { - public static final String TAG = "Kolibri.FuturesUtil"; - - public static CompletableFuture toCompletable(ListenableFuture future, Executor executor) { - CompletableFuture completableFuture = new CompletableFuture<>(); - - future.addListener(() -> { - try { - completableFuture.complete(future.get(3, java.util.concurrent.TimeUnit.SECONDS)); - } catch (InterruptedException | ExecutionException e) { - Log.d(TAG, "Future encountered exception"); - completableFuture.completeExceptionally(e); - } catch (java.util.concurrent.TimeoutException e) { - Log.d(TAG, "Future timed out"); - completableFuture.completeExceptionally(e); - } - }, executor); - - completableFuture.whenCompleteAsync((result, error) -> { - if (completableFuture.isCancelled()) { - Log.d(TAG, "Propagating cancellation to future"); - future.cancel(true); - } - }, executor); - - return completableFuture; - } -} diff --git a/python-for-android/dists/kolibri/src/main/java/org/learningequality/Kolibri/ServiceRemoteshell.java b/python-for-android/dists/kolibri/src/main/java/org/learningequality/Kolibri/ServiceRemoteshell.java deleted file mode 100644 index 3fae5738..00000000 --- a/python-for-android/dists/kolibri/src/main/java/org/learningequality/Kolibri/ServiceRemoteshell.java +++ /dev/null @@ -1,86 +0,0 @@ -package org.learningequality.Kolibri; - -import android.content.Intent; -import android.content.Context; -import org.kivy.android.PythonService; -import android.util.Log; - -public class ServiceRemoteshell extends PythonService { - - - @Override - protected int getServiceId() { - return 1; - } - - static private void _start(Context ctx, String smallIconName, - String contentTitle, String contentText, - String pythonServiceArgument) { - Intent intent = getDefaultIntent(ctx, smallIconName, contentTitle, - contentText, pythonServiceArgument); - ctx.startService(intent); - } - - static public void start(Context ctx, String pythonServiceArgument) { - _start(ctx, "", "Kolibri", "Remoteshell", pythonServiceArgument); - } - - static public void start(Context ctx, String smallIconName, - String contentTitle, String contentText, - String pythonServiceArgument) { - _start(ctx, smallIconName, contentTitle, contentText, pythonServiceArgument); - } - - static public Intent getDefaultIntent(Context ctx, String smallIconName, - String contentTitle, String contentText, - String pythonServiceArgument) { - Intent intent = new Intent(ctx, ServiceRemoteshell.class); - - return putExtraRemoteShell(intent, ctx, smallIconName, contentTitle, contentText, pythonServiceArgument); - - } - - static public Intent putExtraRemoteShell(Intent intent, Context context, String smallIconName, String contentTitle, String contentText, - String pythonServiceArgument) { - String argument = context.getFilesDir().getAbsolutePath() + "/app"; - intent.putExtra("androidPrivate", context.getFilesDir().getAbsolutePath()); - intent.putExtra("androidArgument", argument); - intent.putExtra("serviceTitle", "Kolibri"); - intent.putExtra("serviceEntrypoint", "remoteshell.py"); - intent.putExtra("pythonName", "remoteshell"); - intent.putExtra("serviceStartAsForeground", "false"); - intent.putExtra("pythonHome", argument); - intent.putExtra("pythonPath", argument + ":" + argument + "/lib"); - intent.putExtra("pythonServiceArgument", pythonServiceArgument); - intent.putExtra("smallIconName", smallIconName); - intent.putExtra("contentTitle", contentTitle); - intent.putExtra("contentText", contentText); - return intent; - - } - - @Override - public int onStartCommand(Intent intent, int flags, int startId) { - Context context = getApplicationContext(); - if (intent == null) { - intent = getThisDefaultIntent(context, ""); - } - else { - intent = putExtraRemoteShell(intent, context, "", "kolibri", "ssh service", ""); - } - Log.d("python AndroidRuntime Remoteshell", "Service starting"); - return super.onStartCommand(intent, flags, startId); - } - - @Override - protected Intent getThisDefaultIntent(Context ctx, String pythonServiceArgument) { - return ServiceRemoteshell.getDefaultIntent(ctx, "", "", "", - pythonServiceArgument); - } - - static public void stop(Context ctx) { - Intent intent = new Intent(ctx, ServiceRemoteshell.class); - ctx.stopService(intent); - Log.d("python AndroidRuntime Remoteshell", "Service stopped"); - } -} diff --git a/python-for-android/dists/kolibri/src/main/java/org/learningequality/Kolibri/sqlite/JobStorage.java b/python-for-android/dists/kolibri/src/main/java/org/learningequality/Kolibri/sqlite/JobStorage.java deleted file mode 100644 index e184305f..00000000 --- a/python-for-android/dists/kolibri/src/main/java/org/learningequality/Kolibri/sqlite/JobStorage.java +++ /dev/null @@ -1,81 +0,0 @@ -package org.learningequality.Kolibri.sqlite; - -import android.content.Context; -import android.database.sqlite.SQLiteDatabase; - -import org.learningequality.sqlite.schema.DatabaseTable; -import org.learningequality.sqlite.Database; - -import java.io.File; - -public class JobStorage extends Database { - public static final String DATABASE_NAME = "job_storage.sqlite3"; - - protected JobStorage(String path, int flags) { - super(DATABASE_NAME, path, flags); - } - - public static JobStorage readwrite(Context context) { - File f = getDatabasePath(context, DATABASE_NAME); - - return f != null - ? new JobStorage(f.getPath(), SQLiteDatabase.OPEN_READWRITE) - : null; - } - - public static class Jobs implements DatabaseTable { - public static final String TABLE_NAME = "jobs"; - public static final StringColumn id = new StringColumn("id"); - public static final StringColumn func = new StringColumn("func"); - public static final LongColumn priority = new LongColumn("priority"); - public static final StringColumn worker_process = new StringColumn("worker_process"); - public static final StringColumn worker_thread = new StringColumn("worker_thread"); - public static final StringColumn worker_extra = new StringColumn("worker_extra"); - public static final StringColumn time_updated = new StringColumn("time_updated"); - public static final StringColumn state = new StringColumn("state"); - - public String getTableName() { - return TABLE_NAME; - } - - public enum State implements StringChoiceEnum { - PENDING, - QUEUED, - SCHEDULED, - SELECTED, - RUNNING, - CANCELING, - CANCELED, - FAILED, - COMPLETED; - - public StringColumn getColumn() { - return state; - } - } - - public enum Priority implements ColumnEnum { - LOW(15L), - REGULAR(10L), - HIGH(5L); - - private final Long value; - - Priority(Long val) { - this.value = val; - } - - public Long getValue() { - return this.value; - } - - public boolean isAtLeast(Long other) { - return this.value.compareTo(other) >= 0; - } - - public LongColumn getColumn() { - return priority; - } - } - } -} diff --git a/python-for-android/dists/kolibri/src/main/java/org/learningequality/Kolibri/task/Builder.java b/python-for-android/dists/kolibri/src/main/java/org/learningequality/Kolibri/task/Builder.java deleted file mode 100644 index 5c84a166..00000000 --- a/python-for-android/dists/kolibri/src/main/java/org/learningequality/Kolibri/task/Builder.java +++ /dev/null @@ -1,194 +0,0 @@ -package org.learningequality.Kolibri.task; - -import android.os.Bundle; -import android.util.Log; - -import androidx.work.Data; -import androidx.work.ListenableWorker; -import androidx.work.OneTimeWorkRequest; -import androidx.work.OutOfQuotaPolicy; -import androidx.work.WorkInfo; -import androidx.work.WorkQuery; - -import org.learningequality.Kolibri.BackgroundWorker; -import org.learningequality.Kolibri.ForegroundWorker; -import org.learningequality.Kolibri.sqlite.JobStorage; -import org.learningequality.task.Worker; - -import java.util.Arrays; -import java.util.UUID; -import java.util.concurrent.TimeUnit; - - -/** - * A builder class consolidating logic for creating WorkRequests and WorkQueries - */ -public class Builder { - public static final String TAG = "Kolibri.TaskBuilder"; - - public static final String TAG_PREFIX_TASK_ID = "kolibri_task_id:"; - public static final String TAG_PREFIX_JOB_FUNC = "kolibri_job_type:"; - public static final String TAG_EXPEDITED = "kolibri_job_expedited"; - public static final String TAG_LONG_RUNNING = "kolibri_job_long_running"; - - public static String generateTagFromId(String id) { - return TAG_PREFIX_TASK_ID + id; - } - - public static String generateTagFromJobFunc(String jobFunc) { - return TAG_PREFIX_JOB_FUNC + jobFunc; - } - - /** - * A builder class for creating WorkQueries - */ - public static class TaskQuery { - private final WorkQuery.Builder builder; - - public TaskQuery(WorkQuery.Builder builder) { - this.builder = builder; - } - - public static TaskQuery from(String... jobIds) { - return new TaskQuery(WorkQuery.Builder.fromUniqueWorkNames(Arrays.asList(jobIds))); - } - - public static TaskQuery from(UUID... requestIds) { - return new TaskQuery(WorkQuery.Builder.fromIds(Arrays.asList(requestIds))); - } - - public WorkQuery build() { - return this.builder.build(); - } - } - - /** - * A builder class for creating WorkRequests - * Unfortunately, OneTimeWorkRequest.Builder is final so we cannot extend it. - */ - public static class TaskRequest { - private final String id; - private String jobFunc; - private boolean longRunning; - private int delay; - private boolean expedite; - - public TaskRequest(String id) { - this.id = id; - setDelay(0); - } - - /** - * Creates a TaskRequest builder from a job Bundle, like that returned by JobStorage - * - * @param job The existing job Bundle from which to parse task information - * @return A TaskRequest builder - */ - public static TaskRequest fromJob(Bundle job) { - String id = JobStorage.Jobs.id.getValue(job); - Long priority = JobStorage.Jobs.priority.getValue(job); - - TaskRequest builder = new TaskRequest(id); - return builder.setJobFunc(JobStorage.Jobs.func.getValue(job)) - .setExpedite(JobStorage.Jobs.Priority.HIGH.isAtLeast(priority)); - } - - /** - * Creates a TaskRequest builder from an existing WorkInfo object - * - * @param workInfo The existing WorkInfo from which to parse task information - * @return A TaskRequest builder - */ - public static TaskRequest fromWorkInfo(WorkInfo workInfo) { - String id = null; - String jobFunc = null; - boolean expedite = false; - boolean isLongRunning = false; - - for (String tag : workInfo.getTags()) { - if (tag.startsWith(TAG_PREFIX_TASK_ID)) { - id = tag.substring(TAG_PREFIX_TASK_ID.length()); - } else if (tag.startsWith(TAG_PREFIX_JOB_FUNC)) { - jobFunc = tag.substring(TAG_PREFIX_JOB_FUNC.length()); - } else if (tag.equals(TAG_EXPEDITED)) { - expedite = true; - } else if (tag.equals(TAG_LONG_RUNNING)) { - isLongRunning = true; - } - } - - if (id == null || jobFunc == null) { - throw new IllegalArgumentException("WorkInfo is missing required task info"); - } - - return (new TaskRequest(id)) - .setJobFunc(jobFunc) - .setExpedite(expedite) - .setLongRunning(isLongRunning); - } - - public String getId() { - return this.id; - } - - public TaskRequest setDelay(int delay) { - this.delay = delay; - return this; - } - - public TaskRequest setExpedite(boolean expedite) { - this.expedite = expedite; - return this; - } - - public TaskRequest setJobFunc(String jobFunc) { - this.jobFunc = jobFunc; - return this; - } - - public TaskRequest setLongRunning(boolean longRunning) { - this.longRunning = longRunning; - return this; - } - - private Class getWorkerClass() { - return longRunning || expedite ? ForegroundWorker.class : BackgroundWorker.class; - } - - private Data buildInputData() { - String dataArgument = id == null ? "" : id; - Data.Builder builder = new Data.Builder() - .putString(Worker.ARGUMENT_WORKER_ARGUMENT, dataArgument); - Data data = builder.build(); - Log.v(TAG, "Worker request data: " + data.toString()); - return data; - } - - /** - * Build a one-time WorkRequest from the TaskRequest information - * - * @return A OneTimeWorkRequest object - */ - public OneTimeWorkRequest build() { - OneTimeWorkRequest.Builder builder = new OneTimeWorkRequest.Builder(getWorkerClass()); - builder.addTag(generateTagFromId(id)); - builder.addTag(generateTagFromJobFunc(jobFunc)); - if (longRunning) { - builder.addTag(TAG_LONG_RUNNING); - } - builder.setInputData(buildInputData()); - if (delay > 0) { - builder.setInitialDelay(delay, TimeUnit.SECONDS); - } - // Tasks can only be expedited if they are set with no delay. - // This does not appear to be documented, but is evident in the Android Jetpack source code. - // https://android.googlesource.com/platform/frameworks/support/+/HEAD/work/work-runtime/src/main/java/androidx/work/WorkRequest.kt#271 - if (expedite && delay == 0) { - builder.setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST); - builder.addTag(TAG_EXPEDITED); - } - - return builder.build(); - } - } -} 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 deleted file mode 100644 index 6b826762..00000000 --- a/python-for-android/dists/kolibri/src/main/java/org/learningequality/Kolibri/task/Reconciler.java +++ /dev/null @@ -1,208 +0,0 @@ -package org.learningequality.Kolibri.task; - -import android.content.Context; -import android.util.Log; - -import androidx.work.ExistingWorkPolicy; -import androidx.work.OneTimeWorkRequest; -import androidx.work.multiprocess.RemoteWorkManager; - -import org.learningequality.FuturesUtil; -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 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 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) { - this.workManager = workManager; - this.db = db; - this.lockChannel = lockChannel; - this.executor = executor; - - } - - /** - * Create a new Reconciler instance from a Context - * @param context The context to use - * @return A new Reconciler instance - */ - public static Reconciler from(Context context, JobStorage db, Executor executor) { - RemoteWorkManager workManager = RemoteWorkManager.getInstance(context); - return new Reconciler(workManager, db, LockChannel.getInstance(context), 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. - * @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; - } - - // Then start a transaction - Log.d(TAG, "Beginning transaction"); -// db.begin(); - return true; - } - - /** - * 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); - } - } - - /** - * Close the Reconciler, rolling back the database transaction and releasing the lock - */ - public void close() { - // this may be a no-op if closing normally - db.rollback(); - end(); - } - - /** - * (Re)enqueue a WorkRequest from a Sentinel.Result - * @param result The result of a Sentinel check operation - */ - protected CompletableFuture enqueueFrom(Sentinel.Result result) { - // We prefer to create the builder from the WorkInfo, if it exists - Builder.TaskRequest builder = (result.isMissing()) - ? Builder.TaskRequest.fromJob(result.getJob()) - : Builder.TaskRequest.fromWorkInfo(result.getWorkInfo()); - - - if (result.isMissing()) { - // if we're missing the WorkInfo, then we can't know if it's supposed to be long running, - // because we don't track `long_running` in the DB, so we can only assume - builder.setLongRunning(true) - .setDelay(0); - } - - Log.d(TAG, "Re-enqueuing job " + builder.getId()); - OneTimeWorkRequest req = builder.build(); - - // Using `REPLACE` here because we want to replace the existing request as a more - // forceful way of ensuring that the request is enqueued, since this is reconciliation - CompletableFuture future = FuturesUtil.toCompletable( - workManager.enqueueUniqueWork(builder.getId(), ExistingWorkPolicy.REPLACE, req), executor - ); - - // Update the request ID in the database - if (updateRequestId(builder.getId(), req.getId()) == 0) { - Log.e(TAG, "Failed to update request ID for job " + builder.getId()); - } - - return future; - } - - /** - * Update the request ID for a job in the database - * @param id The job ID - * @param requestId The new WorkManager request ID - * @return The number of rows updated - */ - protected int updateRequestId(String id, UUID requestId) { - Log.d(TAG, "Updating request ID for job " + id + " to " + requestId); - UpdateQuery q = new UpdateQuery(JobStorage.Jobs.TABLE_NAME) - .where(JobStorage.Jobs.id, id) - .set(JobStorage.Jobs.worker_extra, requestId.toString()); - return q.execute(db); - } - - /** - * Process results from Sentinel checks that found jobs in the given state didn't match - * the expected WorkManager state, or were missing - * @param stateRef The state which Kolibri thinks the job is in - * @param results The results of the Sentinel checks - */ - public CompletableFuture process(StateMap stateRef, Sentinel.Result[] results) { - Log.d(TAG, "Reconciling " + results.length + " jobs for state " + stateRef); - List> futures = new ArrayList>(); - - for (Sentinel.Result result : results) { - switch (stateRef.getJobState()) { - case PENDING: - case QUEUED: - case SCHEDULED: - case SELECTED: - case RUNNING: - futures.add(enqueueFrom(result)); - break; - default: - Log.d(TAG, "No reconciliation for state " + stateRef.getJobState()); - break; - } - } - - // Wait for all the job enqueues to finish - return CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])); - } -} diff --git a/python-for-android/dists/kolibri/src/main/java/org/learningequality/Kolibri/task/Sentinel.java b/python-for-android/dists/kolibri/src/main/java/org/learningequality/Kolibri/task/Sentinel.java deleted file mode 100644 index 7ef49363..00000000 --- a/python-for-android/dists/kolibri/src/main/java/org/learningequality/Kolibri/task/Sentinel.java +++ /dev/null @@ -1,251 +0,0 @@ -package org.learningequality.Kolibri.task; - -import android.content.Context; -import android.os.Bundle; -import android.util.Log; -import android.util.Pair; - -import androidx.work.WorkInfo; -import androidx.work.WorkQuery; -import androidx.work.multiprocess.RemoteWorkManager; - -import org.learningequality.FuturesUtil; -import org.learningequality.Kolibri.sqlite.JobStorage; -import org.learningequality.sqlite.query.SelectQuery; - -import java.util.ArrayList; -import java.util.Arrays; -import java.util.List; -import java.util.UUID; -import java.util.concurrent.Executor; - -import java9.util.concurrent.CompletableFuture; - -/** - * Sentinel (as in watcher) for checking and reconciling Kolibri job status with WorkManager - */ -public class Sentinel { - public static String TAG = "Kolibri.TaskSentinel"; - private final RemoteWorkManager workManager; - private final JobStorage db; - private final Executor executor; - - public Sentinel(RemoteWorkManager workManager, JobStorage db, Executor executor) { - this.workManager = workManager; - this.db = db; - this.executor = executor; - } - - /** - * Create a sentinel - */ - public static Sentinel from(Context context, JobStorage db, Executor executor) { - return new Sentinel( - RemoteWorkManager.getInstance(context), - db, - executor - ); - } - - /** - * Build a query for jobs with the given status - * - * @param jobStatus The job status in the Kolibri database for which to find jobs - * @return A query for jobs with the given status, and subset of selected columns - */ - private SelectQuery buildQuery(JobStorage.Jobs.State jobStatus) { - return new SelectQuery( - JobStorage.Jobs.id, - JobStorage.Jobs.priority, - JobStorage.Jobs.state, - JobStorage.Jobs.worker_process, - JobStorage.Jobs.worker_thread, - JobStorage.Jobs.worker_extra - ) - .from(JobStorage.Jobs.TABLE_NAME) - .where(jobStatus) - .orderBy(JobStorage.Jobs.time_updated, false); - } - - private WorkQuery buildWorkQuery(Bundle result) { - String requestId = JobStorage.Jobs.worker_extra.getValue(result); - - if (requestId == null) { - String id = JobStorage.Jobs.id.getValue(result); - Log.v(TAG, "No request ID found for job " + id); - return Builder.TaskQuery.from(id).build(); - } - - return Builder.TaskQuery.from(UUID.fromString(requestId)).build(); - } - - /** - * Check for jobs with the given status and reconcile them with WorkManager - * Defaults to flagging missing work in WorkManager - * - * @param stateRef The job status in the Kolibri database for which to find jobs - * @return A future that will complete when all jobs have been checked, with a list of jobs - */ - public CompletableFuture check(StateMap stateRef) { - return check(false, stateRef); - } - - /** - * Check for jobs with the given status and reconcile them with WorkManager - * - * @param ignoreMissing Whether to ignore missing work in WorkManager - * @param stateRef The job status in the Kolibri database for which to find jobs - * @return A future that will complete when all jobs have been checked, with a list of jobs - */ - public CompletableFuture check( - boolean ignoreMissing, - StateMap stateRef - ) { - Log.d(TAG, "Checking for jobs in state " + stateRef.getJobState()); - SelectQuery query = buildQuery(stateRef.getJobState()); - Bundle[] jobs = query.execute(db); - - if (jobs == null || jobs.length == 0) { - Log.v(TAG, "No jobs to reconcile for status " + stateRef); - return CompletableFuture.completedFuture(null); - } - - Log.d(TAG, "Cross-referencing " + jobs.length + " jobs with work manager"); - return check(jobs, ignoreMissing, stateRef.getWorkInfoStates()); - } - - /** - * Check for the given jobs (Bundles) and reconciles them with WorkManager - * - * @param jobs The jobs to check - * @param ignoreMissing Whether to ignore missing work in WorkManager - * @param expectedWorkStates The expected WorkManager states for the found jobs - * @return A future that will complete when all jobs have been checked, with a list of jobs - */ - public CompletableFuture check( - Bundle[] jobs, - boolean ignoreMissing, - WorkInfo.State... expectedWorkStates - ) { - final CompletableFuture future = new CompletableFuture<>(); - final List allResults = new ArrayList(jobs.length); - CompletableFuture> chain = CompletableFuture.completedFuture(allResults); - - for (Bundle job : jobs) { - chain = chain.thenComposeAsync((results) -> { - synchronized (future) { - if (future.isCancelled()) { - return CompletableFuture.completedFuture(results); - } - } - - return check(job, ignoreMissing, expectedWorkStates) - .exceptionally((ex) -> { - Log.e(TAG, "Failed to check job '" + JobStorage.Jobs.id.getValue(job) + "'", ex); - return null; - }) - .thenApply((result) -> { - if (result != null) { - results.add(result); - } - return results; - }); - }, executor); - } - - final CompletableFuture> finalChain = chain; - - finalChain.whenCompleteAsync((results, ex) -> { - if (ex != null) { - Log.e(TAG, "Failed to check jobs", ex); - future.completeExceptionally(ex); - return; - } - - synchronized (future) { - if (!future.isCancelled()) { - future.complete(results.toArray(new Result[0])); - } - } - }, executor); - - future.whenCompleteAsync((results, ex) -> { - synchronized (future) { - if (future.isCancelled()) { - Log.d(TAG, "Propagating cancellation to future"); - synchronized (finalChain) { - finalChain.cancel(true); - } - } - } - }, executor); - return future; - } - - /** - * Check for the given job (Bundle) and reconciles it with WorkManager - * - * @param job The job to check as a `Bundle` - * @param ignoreMissing Whether to ignore the job as missing in WorkManager - * @param expectedWorkStates The expected WorkManager states for the found jobs - * @return A future that will complete when the job has been checked, with the job if it is not reconciled - */ - public CompletableFuture check( - Bundle job, - boolean ignoreMissing, - WorkInfo.State... expectedWorkStates - ) { - final String jobId = JobStorage.Jobs.id.getValue(job); - Log.d(TAG, "Cross-referencing job '" + jobId + "' with work manager"); - - List workStates = Arrays.asList(expectedWorkStates); - WorkQuery workQuery = buildWorkQuery(job); - - return FuturesUtil.toCompletable(workManager.getWorkInfos(workQuery), executor) - .thenApplyAsync((workInfos) -> { - Log.d(TAG, "Completed cross-reference of job '" + jobId + "'"); - - if (workInfos == null || workInfos.size() == 0) { - if (ignoreMissing) { - return null; - } - - Log.w(TAG, "No work requests found for job id '" + jobId + "'"); - return new Result(job, null); - } - - for (WorkInfo workInfo : workInfos) { - WorkInfo.State state = workInfo.getState(); - - if (!workStates.contains(state)) { - Log.w(TAG, "WorkInfo state " + state + " does not match expected state " + Arrays.toString(expectedWorkStates) + " for request " + workInfo.getId() + " | " + workInfo.getTags()); - return new Result(job, workInfo); - } - } - - return null; - }, executor); - } - - /** - * A class that holds the pair of Bundle and WorkInfo as a result of the Sentinel's - * check operations - */ - public static class Result extends Pair { - public Result(Bundle first, WorkInfo second) { - super(first, second); - } - - public boolean isMissing() { - return this.second == null; - } - - public Bundle getJob() { - return this.first; - } - - public WorkInfo getWorkInfo() { - return this.second; - } - } -} diff --git a/python-for-android/dists/kolibri/src/main/java/org/learningequality/Kolibri/task/StateMap.java b/python-for-android/dists/kolibri/src/main/java/org/learningequality/Kolibri/task/StateMap.java deleted file mode 100644 index 7d50e835..00000000 --- a/python-for-android/dists/kolibri/src/main/java/org/learningequality/Kolibri/task/StateMap.java +++ /dev/null @@ -1,69 +0,0 @@ -package org.learningequality.Kolibri.task; - -import androidx.work.WorkInfo; - -import org.learningequality.Kolibri.sqlite.JobStorage; - -/** - * A mapping between Kolibri job states and WorkManager work states - */ -public enum StateMap { - PENDING( - JobStorage.Jobs.State.PENDING, - WorkInfo.State.ENQUEUED, - WorkInfo.State.BLOCKED, - WorkInfo.State.RUNNING - ), - QUEUED( - JobStorage.Jobs.State.QUEUED, - WorkInfo.State.ENQUEUED, - WorkInfo.State.BLOCKED, - WorkInfo.State.RUNNING - ), - SCHEDULED( - JobStorage.Jobs.State.SCHEDULED, - WorkInfo.State.ENQUEUED, - WorkInfo.State.BLOCKED, - WorkInfo.State.RUNNING - ), - SELECTED( - JobStorage.Jobs.State.SELECTED, - WorkInfo.State.ENQUEUED, - WorkInfo.State.BLOCKED, - WorkInfo.State.RUNNING - ), - // We include 'ENQUEUED' here because it is possible for a job to be re-enqueued by the - // reconciler while Kolibri thinks it's running - RUNNING( - JobStorage.Jobs.State.RUNNING, - WorkInfo.State.ENQUEUED, - WorkInfo.State.RUNNING, - WorkInfo.State.SUCCEEDED - ); - - private final JobStorage.Jobs.State jobState; - private final WorkInfo.State[] workInfoStates; - - StateMap(JobStorage.Jobs.State jobState, WorkInfo.State... workInfoStates) { - this.jobState = jobState; - this.workInfoStates = workInfoStates; - } - - public static StateMap[] forReconciliation() { - return new StateMap[]{ - PENDING, - QUEUED, - SCHEDULED, - SELECTED, - RUNNING - }; - } - - public JobStorage.Jobs.State getJobState() { - return this.jobState; - } - - public WorkInfo.State[] getWorkInfoStates() { - return this.workInfoStates; - } -} diff --git a/python-for-android/dists/kolibri/src/main/java/org/learningequality/MainThreadExecutor.java b/python-for-android/dists/kolibri/src/main/java/org/learningequality/MainThreadExecutor.java deleted file mode 100644 index e9e80957..00000000 --- a/python-for-android/dists/kolibri/src/main/java/org/learningequality/MainThreadExecutor.java +++ /dev/null @@ -1,17 +0,0 @@ -package org.learningequality; - -import android.os.Handler; -import android.os.Looper; - -import java.util.concurrent.Executor; - -// Ref: https://github.com/square/retrofit/blob/master/retrofit/src/main/java/retrofit2/Platform.java#L351 - -public class MainThreadExecutor implements Executor { - private final Handler handler = new Handler(Looper.getMainLooper()); - - @Override - public void execute(Runnable r) { - handler.post(r); - } -} diff --git a/python-for-android/dists/kolibri/src/main/java/org/learningequality/sqlite/Database.java b/python-for-android/dists/kolibri/src/main/java/org/learningequality/sqlite/Database.java deleted file mode 100644 index 74ec088e..00000000 --- a/python-for-android/dists/kolibri/src/main/java/org/learningequality/sqlite/Database.java +++ /dev/null @@ -1,104 +0,0 @@ -package org.learningequality.sqlite; - -import android.content.Context; -import android.database.sqlite.SQLiteDatabase; -import android.database.sqlite.SQLiteException; -import android.util.Log; - -import java.io.File; - -public class Database implements AutoCloseable { - public static final String TAG = "KolibriDatabase"; - - private final String name; - private final String path; - private final int flags; - private boolean inTransaction; - private SQLiteDatabase db; - - protected Database(String name, String path, int flags) { - this.name = name; - this.path = path; - this.db = null; - this.flags = flags; - this.inTransaction = false; - initialize(); - } - - public boolean isConnected() { - return this.path != null && this.db != null; - } - - public SQLiteDatabase get() { - if (!isConnected()) { - throw new IllegalStateException("Database is not connected"); - } - return this.db; - } - - public String getName() { - return this.name; - } - - protected void initialize() { - if (this.path == null) { - return; - } - try { - Log.d(TAG, "Connecting to database"); - this.db = SQLiteDatabase.openDatabase(this.path, null, flags); - } catch (SQLiteException e) { - this.db = null; - } - } - - public void begin() { - if (!isConnected()) { - return; - } - Log.d(TAG, "Starting transaction"); - this.inTransaction = true; - this.db.beginTransaction(); - } - - public void rollback() { - if (!isConnected() || !this.inTransaction) { - return; - } - Log.d(TAG, "Rolling back transaction"); - this.inTransaction = false; - this.db.endTransaction(); - } - - public void commit() { - if (!isConnected() || !this.inTransaction) { - return; - } - Log.d(TAG, "Committing transaction"); - this.inTransaction = false; - this.db.setTransactionSuccessful(); - this.db.endTransaction(); - } - - public void close() { - if (isConnected()) { - Log.d(TAG, "Closing database"); - rollback(); - this.db.close(); - this.db = null; - } - } - - protected static File getDatabasePath(Context context, String name) { - File dir = context.getExternalFilesDir(null); - if (dir != null) { - File f = new File(new File(dir, "KOLIBRI_DATA"), name); - if (f.exists()) { - return f; - } else { - Log.v(TAG, "Database file does not exist: " + f.getPath()); - } - } - return null; - } -} diff --git a/python-for-android/dists/kolibri/src/main/java/org/learningequality/sqlite/query/FilterableQuery.java b/python-for-android/dists/kolibri/src/main/java/org/learningequality/sqlite/query/FilterableQuery.java deleted file mode 100644 index 40473c01..00000000 --- a/python-for-android/dists/kolibri/src/main/java/org/learningequality/sqlite/query/FilterableQuery.java +++ /dev/null @@ -1,49 +0,0 @@ -package org.learningequality.sqlite.query; - -import org.learningequality.sqlite.schema.DatabaseTable; - -import java.util.ArrayList; -import java.util.Arrays; -import java.util.List; - -/** - * A base query that can be filtered - */ -public abstract class FilterableQuery> { - private final List whereClauses; - private final List whereParameters; - - public FilterableQuery() { - this.whereClauses = new ArrayList(); - this.whereParameters = new ArrayList(); - } - - public T where(String clause, String... parameters) { - this.whereClauses.add(clause); - this.whereParameters.addAll(Arrays.asList(parameters)); - return self(); - } - - public T where(DatabaseTable.Column column, String value) { - return where(column.getColumnName() + " = ?", value); - } - - public T where(DatabaseTable.ColumnEnum value) { - return where(value.getColumn(), value.getValue()); - } - - protected String buildSelection() { - // Currently we only support ANDing all where clauses - return String.join(" AND ", this.whereClauses); - } - - protected String[] buildSelectionArgs() { - return this.whereParameters.toArray(new String[this.whereParameters.size()]); - } - - /** - * Method to return the current instance of the query - * @return the current instance of the query - */ - protected abstract T self(); -} diff --git a/python-for-android/dists/kolibri/src/main/java/org/learningequality/sqlite/query/Query.java b/python-for-android/dists/kolibri/src/main/java/org/learningequality/sqlite/query/Query.java deleted file mode 100644 index dfbf657a..00000000 --- a/python-for-android/dists/kolibri/src/main/java/org/learningequality/sqlite/query/Query.java +++ /dev/null @@ -1,10 +0,0 @@ -package org.learningequality.sqlite.query; - -import org.learningequality.sqlite.Database; - -/** - * A SQL query interface that defines a method to execute the query - */ -public interface Query { - T execute(Database db); -} diff --git a/python-for-android/dists/kolibri/src/main/java/org/learningequality/sqlite/query/SelectQuery.java b/python-for-android/dists/kolibri/src/main/java/org/learningequality/sqlite/query/SelectQuery.java deleted file mode 100644 index cadbeb32..00000000 --- a/python-for-android/dists/kolibri/src/main/java/org/learningequality/sqlite/query/SelectQuery.java +++ /dev/null @@ -1,113 +0,0 @@ -package org.learningequality.sqlite.query; - -import android.database.Cursor; -import android.os.Bundle; - -import org.learningequality.sqlite.Database; -import org.learningequality.sqlite.schema.DatabaseTable; - -import java.util.ArrayList; -import java.util.Arrays; -import java.util.List; - -/** - * A query that SELECTs rows from a table - */ -public class SelectQuery extends FilterableQuery implements Query { - private String tableName; - private final DatabaseTable.Column[] selectColumns; - private String orderBy; - - public SelectQuery(DatabaseTable.Column... columns) { - this.selectColumns = columns.length > 0 ? columns : null; - } - - /** - * Method to return the current instance of the query - * @return the current instance of the query - */ - @Override - protected SelectQuery self() { - return this; - } - - public SelectQuery from(String tableName) { - this.tableName = tableName; - return this; - } - - public SelectQuery orderBy(DatabaseTable.Column column, boolean ascending) { - this.orderBy = column.getColumnName() + (ascending ? " ASC" : " DESC"); - return this; - } - - protected Bundle buildBundle(Database db, Cursor cursor) { - Bundle b = new Bundle(cursor.getColumnCount() + 2); - b.putString(DatabaseTable.DATABASE_NAME, db.getName()); - b.putString(DatabaseTable.TABLE_NAME, this.tableName); - - for (int i = 0; i < cursor.getColumnCount(); i++) { - String columnName = cursor.getColumnName(i); - switch (cursor.getType(i)) { - case Cursor.FIELD_TYPE_NULL: - b.putString(columnName, null); - break; - case Cursor.FIELD_TYPE_INTEGER: - b.putLong(columnName, cursor.getLong(i)); - break; - case Cursor.FIELD_TYPE_FLOAT: - b.putDouble(columnName, cursor.getDouble(i)); - break; - case Cursor.FIELD_TYPE_STRING: - b.putString(columnName, cursor.getString(i)); - break; - case Cursor.FIELD_TYPE_BLOB: - b.putByteArray(columnName, cursor.getBlob(i)); - break; - } - } - return b; - } - - protected String[] generateSelectColumns() { - if (this.selectColumns == null) { - return null; - } - - // This can be simpler with Java 8 streams - List selectColumns = new ArrayList(); - for (DatabaseTable.Column column : this.selectColumns) { - selectColumns.add(column.getColumnName()); - } - - return selectColumns.toArray(new String[0]); - } - - public Bundle[] execute(Database db) { - if (!db.isConnected()) { - return null; - } - - try { - List results; - try (Cursor cursor = db.get().query( - this.tableName, - this.generateSelectColumns(), - buildSelection(), - buildSelectionArgs(), - null, - null, - this.orderBy - )) { - results = new ArrayList(); - while (cursor.moveToNext()) { - results.add(buildBundle(db, cursor)); - } - } - return results.toArray(new Bundle[0]); - } catch (Exception e) { - e.printStackTrace(); - } - return null; - } -} diff --git a/python-for-android/dists/kolibri/src/main/java/org/learningequality/sqlite/query/UpdateQuery.java b/python-for-android/dists/kolibri/src/main/java/org/learningequality/sqlite/query/UpdateQuery.java deleted file mode 100644 index b452b08b..00000000 --- a/python-for-android/dists/kolibri/src/main/java/org/learningequality/sqlite/query/UpdateQuery.java +++ /dev/null @@ -1,50 +0,0 @@ -package org.learningequality.sqlite.query; - -import org.learningequality.sqlite.Database; -import org.learningequality.sqlite.schema.DatabaseTable; - -import android.content.ContentValues; - -/** - * A class that represents an UPDATE SQL query - */ -public class UpdateQuery extends FilterableQuery implements Query { - private final String tableName; - private ContentValues values; - - public UpdateQuery(String tableName) { - this.tableName = tableName; - this.values = new ContentValues(); - } - - /** - * Method to return the current instance of the query - * @return the current instance of the query - */ - @Override - protected UpdateQuery self() { - return this; - } - - public UpdateQuery set(ContentValues values) { - this.values = values; - return this; - } - - public UpdateQuery set(DatabaseTable.Column column, String value) { - this.values.put(column.getColumnName(), value); - return this; - } - - public Integer execute(Database db) { - if (!db.isConnected()) { - return 0; - } - - if (this.values.size() == 0) { - throw new IllegalStateException("No values to update"); - } - - return db.get().update(this.tableName, this.values, buildSelection(), buildSelectionArgs()); - } -} diff --git a/python-for-android/dists/kolibri/src/main/java/org/learningequality/sqlite/schema/DatabaseTable.java b/python-for-android/dists/kolibri/src/main/java/org/learningequality/sqlite/schema/DatabaseTable.java deleted file mode 100644 index 6f4f6316..00000000 --- a/python-for-android/dists/kolibri/src/main/java/org/learningequality/sqlite/schema/DatabaseTable.java +++ /dev/null @@ -1,64 +0,0 @@ -package org.learningequality.sqlite.schema; - -import android.os.Bundle; - -public interface DatabaseTable { - String DATABASE_NAME = "DATABASE_NAME"; - String TABLE_NAME = "TABLE_NAME"; - - String getTableName(); - - interface Column { - String getColumnName(); - } - - interface ColumnEnum extends Column { - String name(); - - T getValue(); - - ColumnImpl getColumn(); - - default String getColumnName() { - return getColumn().getColumnName(); - } - } - - interface StringChoiceEnum extends ColumnEnum { - default String getValue() { - return this.name(); - } - } - - abstract class ColumnImpl implements Column { - private final String columnName; - - public ColumnImpl(String columnName) { - this.columnName = columnName; - } - - public String getColumnName() { - return this.columnName; - } - } - - class StringColumn extends ColumnImpl { - public StringColumn(String columnName) { - super(columnName); - } - - public String getValue(Bundle bundle) { - return bundle.getString(getColumnName()); - } - } - - class LongColumn extends ColumnImpl { - public LongColumn(String columnName) { - super(columnName); - } - - public Long getValue(Bundle bundle) { - return bundle.getLong(getColumnName()); - } - } -} diff --git a/python-for-android/dists/kolibri/src/main/java/org/learningequality/task/Worker.java b/python-for-android/dists/kolibri/src/main/java/org/learningequality/task/Worker.java deleted file mode 100644 index 8ee30d76..00000000 --- a/python-for-android/dists/kolibri/src/main/java/org/learningequality/task/Worker.java +++ /dev/null @@ -1,159 +0,0 @@ -package org.learningequality.task; - -import android.app.Notification; -import android.content.Context; -import android.util.Log; - -import androidx.annotation.NonNull; -import androidx.work.Data; -import androidx.work.WorkerParameters; - -import org.kivy.android.PythonProvider; -import org.learningequality.Kolibri.sqlite.JobStorage; -import org.learningequality.Kolibri.task.TaskWorkerImpl; -import org.learningequality.notification.Notifier; -import org.learningequality.notification.NotificationRef; -import org.learningequality.sqlite.query.UpdateQuery; - -import java.util.UUID; -import java.util.zip.CRC32; - -/** - * Abstract worker class that executes a task worker implementation - */ -abstract public class Worker extends androidx.work.Worker implements Notifier { - public static String TAG = "Kolibri.BaseWorker"; - public static String ARGUMENT_WORKER_ARGUMENT = "PYTHON_WORKER_ARGUMENT"; - private int lastProgressUpdateHash; - private Notification lastNotification; - - - public Worker( - @NonNull Context context, @NonNull WorkerParameters workerParams - ) { - super(context, workerParams); - } - - /** - * Parent worker class will call this method on a background thread automatically - * when work is to be executed. - */ - protected abstract WorkerImpl getWorkerImpl(); - - /** - * Parent worker class will call this method on a background thread automatically. - */ - @Override - @NonNull - public Result doWork() { - final String id = getId().toString(); - final String arg = getArgument(); - Result r; - Log.d(TAG, "Executing task implementation: " + getId()); - try (WorkerImpl workerImpl = getWorkerImpl()) { - workerImpl.addObserver(new Observer() { - @Override - public void update(TaskWorkerImpl.Message message) { - onProgressUpdate(message); - } - }); - // Provide context to PythonProvider - try (PythonProvider ignored = PythonProvider.create(getApplicationContext())) { - boolean result = workerImpl.execute(id,arg); - if (!result) { - if (updateTaskStatus(getArgument(), JobStorage.Jobs.State.FAILED.toString()) == 0) { - Log.e(TAG, "Failed to update TaskStatus for remote Task " + getId()); - } - } - r = result ? Result.success() : Result.failure(); - } - } catch (Exception e) { - Log.e(TAG, "Error executing task implementation: " + getId(), e); - r = Result.failure(); - } - hideNotification(); - return r; - } - - @Override - public void onStopped() { - Log.d(TAG, "Stopping background remote task " + getId()); - // Here we need to update the task in the database to be marked as failed - if (updateTaskStatus(getArgument(), JobStorage.Jobs.State.CANCELED.toString()) == 0) { - Log.e(TAG, "Failed to update TaskStatus for remote Task " + getId()); - } - hideNotification(); - super.onStopped(); - } - - protected int updateTaskStatus(String id, String stateToBeUpdatedTo) { - int result = 0; - try (JobStorage db = JobStorage.readwrite(getApplicationContext())) { - if (db!=null) { - Log.d(TAG, "Updating Task Status for job " + id); - UpdateQuery q = new UpdateQuery(JobStorage.Jobs.TABLE_NAME) - .where(JobStorage.Jobs.id, id) - .set(JobStorage.Jobs.state, stateToBeUpdatedTo); - result = q.execute(db); - } - Log.e(TAG, "Failed to initialize JobStorage"); - }catch (Exception e) { - Log.e(TAG, "Error managing JobStorage", e); - } - return result; - } - - - protected Notification getLastNotification() { - return lastNotification; - } - - protected void onProgressUpdate(TaskWorkerImpl.Message message) { - Data updateData = message.toData(); - // Only update progress if it has changed - if (updateData.hashCode() == lastProgressUpdateHash) { - return; - } - lastProgressUpdateHash = updateData.hashCode(); - // Logs the data to debug logging - setProgressAsync(updateData); - try { - lastNotification = sendNotification( - message.notificationTitle, - message.notificationText, - message.progress, - message.totalProgress - ); - } catch (Exception e) { - Log.e(TAG, "Failed to update task progress for: " + getId(), e); - } - } - - protected String getArgument() { - String dataArg = getInputData().getString(ARGUMENT_WORKER_ARGUMENT); - final String serviceArg; - if (dataArg != null) { - serviceArg = dataArg; - } else { - serviceArg = ""; - } - return serviceArg; - } - - public NotificationRef getNotificationRef() { - // Use worker request ID as notification tag - return buildNotificationRef(getId()); - } - - public static NotificationRef buildNotificationRef(UUID id) { - return buildNotificationRef(id.toString()); - } - - public static NotificationRef buildNotificationRef(String id) { - // Use CRC32 to generate a unique, integer, notification ID from the string request ID - CRC32 crc = new CRC32(); - crc.update(id.getBytes()); - int notificationId = (int) crc.getValue(); // Use lower 32 bits (truncates) - return new NotificationRef(NotificationRef.REF_CHANNEL_DEFAULT, notificationId); - } -} diff --git a/python-for-android/dists/kolibri/src/main/java/org/learningequality/task/WorkerImpl.java b/python-for-android/dists/kolibri/src/main/java/org/learningequality/task/WorkerImpl.java deleted file mode 100644 index 612a6b75..00000000 --- a/python-for-android/dists/kolibri/src/main/java/org/learningequality/task/WorkerImpl.java +++ /dev/null @@ -1,9 +0,0 @@ -package org.learningequality.task; - -/** - * Interface for defining a worker implementation that can be observed for updates, and handles - * execution of a task, and cleanup of resources implementing AutoCloseable. - */ -public interface WorkerImpl extends Observable, AutoCloseable { - boolean execute(String id, String arg); -} diff --git a/python-for-android/dists/kolibri/src/main/java/org/renpy/android/AssetExtract.java b/python-for-android/dists/kolibri/src/main/java/org/renpy/android/AssetExtract.java deleted file mode 100644 index 6ec67b13..00000000 --- a/python-for-android/dists/kolibri/src/main/java/org/renpy/android/AssetExtract.java +++ /dev/null @@ -1,117 +0,0 @@ -// This string is autogenerated by ChangeAppSettings.sh, do not change -// spaces amount -package org.renpy.android; - -import android.content.Context; -import android.util.Log; - -import java.io.BufferedInputStream; -import java.io.BufferedOutputStream; -import java.io.IOException; -import java.io.InputStream; -import java.io.OutputStream; -import java.io.FileOutputStream; -import java.io.FileNotFoundException; -import java.io.File; -import java.io.FileInputStream; - -import java.util.zip.GZIPInputStream; - -import android.content.res.AssetManager; -import org.kamranzafar.jtar.TarEntry; -import org.kamranzafar.jtar.TarInputStream; - -public class AssetExtract { - - private AssetManager mAssetManager = null; - - public AssetExtract(Context context) { - mAssetManager = context.getAssets(); - } - - public boolean extractTar(String asset, String target, String method) { - - byte buf[] = new byte[1024 * 1024]; - - InputStream assetStream = null; - TarInputStream tis = null; - - try { - if(method == "private"){ - assetStream = mAssetManager.open(asset, AssetManager.ACCESS_STREAMING); - } else if (method == "pybundle") { - assetStream = new FileInputStream(asset); - } - - tis = new TarInputStream(new BufferedInputStream(new GZIPInputStream(new BufferedInputStream(assetStream, 8192)), 8192)); - } catch (IOException e) { - Log.e("python", "opening up extract tar", e); - return false; - } - - while (true) { - TarEntry entry = null; - - try { - entry = tis.getNextEntry(); - } catch ( IOException e ) { - Log.e("python", "extracting tar", e); - return false; - } - - if ( entry == null ) { - break; - } - - Log.v("python", "extracting " + entry.getName()); - - if (entry.isDirectory()) { - - try { - new File(target +"/" + entry.getName()).mkdirs(); - } catch ( SecurityException e ) { }; - - continue; - } - - OutputStream out = null; - String path = target + "/" + entry.getName(); - - try { - out = new BufferedOutputStream(new FileOutputStream(path), 8192); - } catch ( FileNotFoundException | SecurityException e ) {} - - if ( out == null ) { - Log.e("python", "could not open " + path); - return false; - } - - try { - while (true) { - int len = tis.read(buf); - - if (len == -1) { - break; - } - - out.write(buf, 0, len); - } - - out.flush(); - out.close(); - } catch ( IOException e ) { - Log.e("python", "extracting zip", e); - return false; - } - } - - try { - tis.close(); - assetStream.close(); - } catch (IOException e) { - // pass - } - - return true; - } -} diff --git a/python-for-android/dists/kolibri/src/main/java/org/renpy/android/Hardware.java b/python-for-android/dists/kolibri/src/main/java/org/renpy/android/Hardware.java deleted file mode 100644 index cdab8c2f..00000000 --- a/python-for-android/dists/kolibri/src/main/java/org/renpy/android/Hardware.java +++ /dev/null @@ -1,279 +0,0 @@ -package org.renpy.android; - -import android.content.Context; -import android.os.Vibrator; -import android.hardware.Sensor; -import android.hardware.SensorEvent; -import android.hardware.SensorEventListener; -import android.hardware.SensorManager; -import android.util.DisplayMetrics; -import android.view.inputmethod.InputMethodManager; -import android.view.View; - -import java.util.List; -import android.net.wifi.ScanResult; -import android.net.wifi.WifiManager; -import android.content.BroadcastReceiver; -import android.content.Intent; -import android.content.IntentFilter; -import android.net.ConnectivityManager; -import android.net.NetworkInfo; - -import org.kivy.android.PythonActivity; - -/** - * Methods that are expected to be called via JNI, to access the - * device's non-screen hardware. (For example, the vibration and - * accelerometer.) - */ -public class Hardware { - - // The context. - static Context context; - static View view; - public static final float defaultRv[] = { 0f, 0f, 0f }; - - /** - * Vibrate for s seconds. - */ - public static void vibrate(double s) { - Vibrator v = (Vibrator) context.getSystemService(Context.VIBRATOR_SERVICE); - if (v != null) { - v.vibrate((int) (1000 * s)); - } - } - - /** - * Get an Overview of all Hardware Sensors of an Android Device - */ - public static String getHardwareSensors() { - SensorManager sm = (SensorManager) context.getSystemService(Context.SENSOR_SERVICE); - List allSensors = sm.getSensorList(Sensor.TYPE_ALL); - - if (allSensors != null) { - String resultString = ""; - for (Sensor s : allSensors) { - resultString += String.format("Name=" + s.getName()); - resultString += String.format(",Vendor=" + s.getVendor()); - resultString += String.format(",Version=" + s.getVersion()); - resultString += String.format(",MaximumRange=" + s.getMaximumRange()); - // XXX MinDelay is not in the 2.2 - //resultString += String.format(",MinDelay=" + s.getMinDelay()); - resultString += String.format(",Power=" + s.getPower()); - resultString += String.format(",Type=" + s.getType() + "\n"); - } - return resultString; - } - return ""; - } - - - /** - * Get Access to 3 Axis Hardware Sensors Accelerometer, Orientation and Magnetic Field Sensors - */ - public static class generic3AxisSensor implements SensorEventListener { - private final SensorManager sSensorManager; - private final Sensor sSensor; - private final int sSensorType; - SensorEvent sSensorEvent; - - public generic3AxisSensor(int sensorType) { - sSensorType = sensorType; - sSensorManager = (SensorManager)context.getSystemService(Context.SENSOR_SERVICE); - sSensor = sSensorManager.getDefaultSensor(sSensorType); - } - - public void onAccuracyChanged(Sensor sensor, int accuracy) { - } - - public void onSensorChanged(SensorEvent event) { - sSensorEvent = event; - } - - /** - * Enable or disable the Sensor by registering/unregistering - */ - public void changeStatus(boolean enable) { - if (enable) { - sSensorManager.registerListener(this, sSensor, SensorManager.SENSOR_DELAY_NORMAL); - } else { - sSensorManager.unregisterListener(this, sSensor); - } - } - - /** - * Read the Sensor - */ - public float[] readSensor() { - if (sSensorEvent != null) { - return sSensorEvent.values; - } else { - return defaultRv; - } - } - } - - public static generic3AxisSensor accelerometerSensor = null; - public static generic3AxisSensor orientationSensor = null; - public static generic3AxisSensor magneticFieldSensor = null; - - /** - * functions for backward compatibility reasons - */ - - public static void accelerometerEnable(boolean enable) { - if ( accelerometerSensor == null ) - accelerometerSensor = new generic3AxisSensor(Sensor.TYPE_ACCELEROMETER); - accelerometerSensor.changeStatus(enable); - } - public static float[] accelerometerReading() { - if ( accelerometerSensor == null ) - return defaultRv; - return (float[]) accelerometerSensor.readSensor(); - } - public static void orientationSensorEnable(boolean enable) { - if ( orientationSensor == null ) - orientationSensor = new generic3AxisSensor(Sensor.TYPE_ORIENTATION); - orientationSensor.changeStatus(enable); - } - public static float[] orientationSensorReading() { - if ( orientationSensor == null ) - return defaultRv; - return (float[]) orientationSensor.readSensor(); - } - public static void magneticFieldSensorEnable(boolean enable) { - if ( magneticFieldSensor == null ) - magneticFieldSensor = new generic3AxisSensor(Sensor.TYPE_MAGNETIC_FIELD); - magneticFieldSensor.changeStatus(enable); - } - public static float[] magneticFieldSensorReading() { - if ( magneticFieldSensor == null ) - return defaultRv; - return (float[]) magneticFieldSensor.readSensor(); - } - - static public DisplayMetrics metrics = new DisplayMetrics(); - - /** - * Get display DPI. - */ - public static int getDPI() { - // AND: Shouldn't have to get the metrics like this every time... - PythonActivity.mActivity.getWindowManager().getDefaultDisplay().getMetrics(metrics); - return metrics.densityDpi; - } - - // /** - // * Show the soft keyboard. - // */ - // public static void showKeyboard(int input_type) { - // //Log.i("python", "hardware.Java show_keyword " input_type); - - // InputMethodManager imm = (InputMethodManager) context.getSystemService(Context.INPUT_METHOD_SERVICE); - - // SDLSurfaceView vw = (SDLSurfaceView) view; - - // int inputType = input_type; - - // if (vw.inputType != inputType){ - // vw.inputType = inputType; - // imm.restartInput(view); - // } - - // imm.showSoftInput(view, InputMethodManager.SHOW_FORCED); - // } - - /** - * Hide the soft keyboard. - */ - public static void hideKeyboard() { - InputMethodManager imm = (InputMethodManager) context.getSystemService(Context.INPUT_METHOD_SERVICE); - imm.hideSoftInputFromWindow(view.getWindowToken(), 0); - } - - /** - * Scan WiFi networks - */ - static List latestResult; - - public static void enableWifiScanner() - { - IntentFilter i = new IntentFilter(); - i.addAction(WifiManager.SCAN_RESULTS_AVAILABLE_ACTION); - - context.registerReceiver(new BroadcastReceiver() { - - @Override - public void onReceive(Context c, Intent i) { - // Code to execute when SCAN_RESULTS_AVAILABLE_ACTION event occurs - WifiManager w = (WifiManager) c.getSystemService(Context.WIFI_SERVICE); - latestResult = w.getScanResults(); // Returns a of scanResults - } - - }, i); - - } - - public static String scanWifi() { - - // Now you can call this and it should execute the broadcastReceiver's - // onReceive() - if (latestResult != null){ - - String latestResultString = ""; - for (ScanResult result : latestResult) - { - latestResultString += String.format("%s\t%s\t%d\n", result.SSID, result.BSSID, result.level); - } - - return latestResultString; - } - - return ""; - } - - /** - * network state - */ - - public static boolean network_state = false; - - /** - * Check network state directly - * - * (only one connection can be active at a given moment, detects all network type) - * - */ - public static boolean checkNetwork() - { - boolean state = false; - final ConnectivityManager conMgr = (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE); - - final NetworkInfo activeNetwork = conMgr.getActiveNetworkInfo(); - if (activeNetwork != null && activeNetwork.isConnected()) { - state = true; - } else { - state = false; - } - - return state; - } - - /** - * To recieve network state changes - */ - public static void registerNetworkCheck() - { - IntentFilter i = new IntentFilter(); - i.addAction(ConnectivityManager.CONNECTIVITY_ACTION); - context.registerReceiver(new BroadcastReceiver() { - - @Override - public void onReceive(Context c, Intent i) { - network_state = checkNetwork(); - } - - }, i); - } - -} diff --git a/python-for-android/dists/kolibri/src/main/java/org/renpy/android/ResourceManager.java b/python-for-android/dists/kolibri/src/main/java/org/renpy/android/ResourceManager.java deleted file mode 100644 index a170c846..00000000 --- a/python-for-android/dists/kolibri/src/main/java/org/renpy/android/ResourceManager.java +++ /dev/null @@ -1,53 +0,0 @@ -/** - * This class takes care of managing resources for us. In our code, we - * can't use R, since the name of the package containing R will - * change. So this is the next best thing. - */ - -package org.renpy.android; - -import android.app.Activity; -import android.content.res.Resources; -import android.view.View; - -import android.util.Log; - -public class ResourceManager { - - private Activity act; - private Resources res; - - public ResourceManager(Activity activity) { - act = activity; - res = act.getResources(); - } - - public int getIdentifier(String name, String kind) { - Log.v("SDL", "getting identifier"); - Log.v("SDL", "kind is " + kind + " and name " + name); - Log.v("SDL", "result is " + res.getIdentifier(name, kind, act.getPackageName())); - return res.getIdentifier(name, kind, act.getPackageName()); - } - - public String getString(String name) { - - try { - Log.v("SDL", "asked to get string " + name); - return res.getString(getIdentifier(name, "string")); - } catch (Exception e) { - Log.v("SDL", "got exception looking for string!"); - return null; - } - } - - public View inflateView(String name) { - int id = getIdentifier(name, "layout"); - return act.getLayoutInflater().inflate(id, null); - } - - public View getViewById(View v, String name) { - int id = getIdentifier(name, "id"); - return v.findViewById(id); - } - -} diff --git a/python-for-android/dists/kolibri/src/main/jniLibs/.gitkeep b/python-for-android/dists/kolibri/src/main/jniLibs/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/python-for-android/dists/kolibri/src/main/res/drawable-hdpi/ic_stat_kolibri_notification.png b/python-for-android/dists/kolibri/src/main/res/drawable-hdpi/ic_stat_kolibri_notification.png deleted file mode 100644 index b2812b995514e091b147b2ee7e88b6cb48bbc86f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1354 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 X{w$br>GHwz00000NkvXXu0mjf#n+?f diff --git a/python-for-android/dists/kolibri/src/main/res/drawable/.gitkeep b/python-for-android/dists/kolibri/src/main/res/drawable/.gitkeep deleted file mode 100644 index e69de29b..00000000 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 deleted file mode 100644 index 3e64e90d..00000000 --- a/python-for-android/dists/kolibri/src/main/res/drawable/baseline_notifications_paused_24.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - diff --git a/python-for-android/dists/kolibri/src/main/res/drawable/presplash.jpg b/python-for-android/dists/kolibri/src/main/res/drawable/presplash.jpg deleted file mode 100644 index a8840efeef7430043c0dad86e9d1d3cf0c8df8cb..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 41593 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

jcFpI(=xGlXI2m#n56PsagT` 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 PI)mL1VrXalPJjQ|yztE* diff --git a/python-for-android/dists/kolibri/src/main/res/layout/main.xml b/python-for-android/dists/kolibri/src/main/res/layout/main.xml deleted file mode 100644 index 1a6fef00..00000000 --- a/python-for-android/dists/kolibri/src/main/res/layout/main.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - diff --git a/python-for-android/dists/kolibri/src/main/res/mipmap-anydpi-v26/.gitkeep b/python-for-android/dists/kolibri/src/main/res/mipmap-anydpi-v26/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/python-for-android/dists/kolibri/src/main/res/mipmap/.gitkeep b/python-for-android/dists/kolibri/src/main/res/mipmap/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/python-for-android/dists/kolibri/src/main/res/mipmap/icon.png b/python-for-android/dists/kolibri/src/main/res/mipmap/icon.png deleted file mode 100644 index dd0e0253de4c6eeb5aeeddd70a4a4ccddec9a2e6..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 72353 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 - - #996189 - 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 deleted file mode 100644 index 13f5e0e6..00000000 --- a/python-for-android/dists/kolibri/src/main/res/values/strings.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - Kolibri - :task_worker - Task started… - Important Tasks Running - In progress - View task manager - Tasks - diff --git a/python-for-android/dists/kolibri/src/main/res/xml/.gitkeep b/python-for-android/dists/kolibri/src/main/res/xml/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/python-for-android/dists/kolibri/src/main/res/xml/file_paths.xml b/python-for-android/dists/kolibri/src/main/res/xml/file_paths.xml deleted file mode 100644 index 1a14190b..00000000 --- a/python-for-android/dists/kolibri/src/main/res/xml/file_paths.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/scripts/rundocker.sh b/scripts/rundocker.sh deleted file mode 100755 index a4ce246f..00000000 --- a/scripts/rundocker.sh +++ /dev/null @@ -1,50 +0,0 @@ -#!/bin/bash - -set -e - -SCRIPTDIR=$(realpath "$(dirname "$0")") -SRCDIR=$(dirname "$SCRIPTDIR") -DOCKER=${DOCKER:-"docker"} - -BUILD_CACHE_VOLUME=kolibri-android-cache -BUILD_CACHE_PATH=/cache -BUILD_UID=$(id -u) -BUILD_GID=$(id -g) - -# Build array of options to pass to docker run. -RUN_OPTS=( - -it --rm - - # Mount the cache volume. - --mount "type=volume,src=${BUILD_CACHE_VOLUME},dst=${BUILD_CACHE_PATH}" - - # Bind mount the source directory into the container and make it the - # working dirctory. - --volume "${SRCDIR}:${SRCDIR}:z" - --workdir "${SRCDIR}" - - # Run as the calling user and make the cache volume the user's home - # directory so all the intermediate build outputs (e.g., - # ~/.local/share/python-for-android and ~/.gradle) are stored. - --user "${BUILD_UID}:${BUILD_GID}" - --env HOME="${BUILD_CACHE_PATH}" - - # Pass through other environment variables. - --env BUILDKITE_BUILD_NUMBER - --env P4A_RELEASE_KEYALIAS - --env P4A_RELEASE_KEYSTORE_PASSWD - --env P4A_RELEASE_KEYALIAS_PASSWD - --env ARCHES -) - -# If the release signing key has been specified and exists, ensure the -# path is absolute and bind mount it readonly into the container. -if [ -e "${P4A_RELEASE_KEYSTORE}" ]; then - P4A_RELEASE_KEYSTORE=$(realpath "${P4A_RELEASE_KEYSTORE}") - RUN_OPTS+=( - --env P4A_RELEASE_KEYSTORE - --volume "${P4A_RELEASE_KEYSTORE}:${P4A_RELEASE_KEYSTORE}:ro,z" - ) -fi - -exec "${DOCKER}" run "${RUN_OPTS[@]}" android_kolibri "$@" diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index a38353a6..00000000 --- a/setup.cfg +++ /dev/null @@ -1,4 +0,0 @@ -[flake8] -ignore = E226,E203,E41,W503,E741 -max-line-length = 160 -max-complexity = 10 diff --git a/src/android_app_plugin/__init__.py b/src/android_app_plugin/__init__.py deleted file mode 100644 index e69de29b..00000000 diff --git a/src/android_app_plugin/kolibri_plugin.py b/src/android_app_plugin/kolibri_plugin.py deleted file mode 100644 index 9176dfd5..00000000 --- a/src/android_app_plugin/kolibri_plugin.py +++ /dev/null @@ -1,116 +0,0 @@ -import logging - -from android_utils import is_active_network_metered -from android_utils import os_user -from android_utils import share_by_intent -from django.utils import timezone -from jnius import autoclass -from kolibri.core.content.hooks import ShareFileHook -from kolibri.core.device.hooks import CheckIsMeteredHook -from kolibri.core.device.hooks import GetOSUserHook -from kolibri.core.tasks.hooks import JobHook -from kolibri.core.tasks.job import Priority -from kolibri.plugins import KolibriPluginBase -from kolibri.plugins.hooks import register_hook - -Locale = autoclass("java.util.Locale") -Task = autoclass("org.learningequality.Task") -TaskWorker = autoclass("org.learningequality.Kolibri.task.TaskWorkerImpl") -PROGRESS_LIMIT = 10000 - - -logger = logging.getLogger(__name__) - - -class AndroidApp(KolibriPluginBase): - pass - - -@register_hook -class AndroidGetOSUserHook(GetOSUserHook): - def get_os_user(self, auth_token=None): - return os_user(auth_token) - - -@register_hook -class AndroidCheckIsMeteredHook(CheckIsMeteredHook): - def check_is_metered(self): - try: - return bool(is_active_network_metered()) - except Exception: - return False - - -@register_hook -class AndroidShareFileHook(ShareFileHook): - def share_file(self, filename, message): - return share_by_intent(filename, message) - - -@register_hook -class AndroidJobHook(JobHook): - def schedule( - self, - job, - orm_job, - ): - if orm_job.id: - - delay = ( - max(0, (orm_job.scheduled_time - timezone.now()).total_seconds()) - if orm_job.scheduled_time - else 0 - ) - - high_priority = orm_job.priority <= Priority.HIGH - - # Android has no mechanism for scheduling a limited run of repeating tasks, - # so we just schedule it as a one-off task, and then re-schedule it when the task - # is completed. - # We could use WorkManager's PeriodicWorkRequest, but this gives us more control - # over execution, and also allows us to use the same mechanism for all tasks. - # Similarly, retry_intervals are handled by the schedule mechanism, so we don't - # leverage Android's retry mechanism either. - logger.info( - "Scheduling task {} for job {} with delay {} and high priority {}".format( - job.func, orm_job.id, delay, high_priority - ) - ) - request_id = Task.enqueueOnce( - orm_job.id, - delay, - high_priority, - job.func, - job.long_running, - ) - job.update_worker_info(extra=request_id) - - def update(self, job, orm_job, state=None, **kwargs): - currentLocale = Locale.getDefault().toLanguageTag() - - status = job.status(currentLocale) - - if status: - if job.total_progress: - progress = job.progress - total_progress = job.total_progress - else: - progress = -1 - total_progress = -1 - - # avoid passing integers that are too large - # PROGRESS_LIMIT gives sufficient precision for a % progress calculation - if total_progress > PROGRESS_LIMIT: - progress = PROGRESS_LIMIT * progress // total_progress - total_progress = PROGRESS_LIMIT - - TaskWorker.notifyLocalObservers( - status.title, - status.text, - progress, - total_progress, - ) - - def clear(self, job, orm_job): - logger.info("Clearing task {} for job {}".format(job.func, orm_job.id)) - Task.clear(orm_job.id) diff --git a/src/android_utils.py b/src/android_utils.py deleted file mode 100644 index 2a6dbb28..00000000 --- a/src/android_utils.py +++ /dev/null @@ -1,254 +0,0 @@ -import json -import os -import re -from functools import cache -from uuid import uuid4 - -from cryptography import x509 -from cryptography.hazmat.backends import default_backend -from i18n import get_string -from jnius import autoclass -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(): - return get_package_info().versionName - - -@cache -def get_context(): - PythonContext = autoclass("org.kivy.android.PythonContext") - return PythonContext.get() - - -@cache -def get_external_files_dir(): - return get_context().getExternalFilesDir(None).toString() - - -# TODO: check for storage availability, allow user to chose sd card or internal -def get_home_folder(): - return os.path.join(get_external_files_dir(), "KOLIBRI_DATA") - - -class AndroidValueCache: - """ - A helper class to cache values to disk that might otherwise be expensive to - query from Android APIs, and that we are pretty sure will be static. - """ - - __slots__ = "_storage_path", "_dict" - - def __init__(self): - self._dict = {} - self._storage_path = None - - def _load(self, key): - try: - with open(os.path.join(self._storage_path, key)) as f: - self._dict[key] = f.read().strip() - except FileNotFoundError: - pass - - def _ensure_storage(self): - if self._storage_path is None: - # Store this in the parent of the Kolibri home dir to prevent collisions. - self._storage_path = os.path.join(get_external_files_dir(), ".value_cache") - if not os.path.exists(self._storage_path): - os.mkdir(self._storage_path) - - def get(self, key): - self._ensure_storage() - if key not in self._dict: - self._load(key) - return self._dict.get(key) - - def set(self, key, value): - self._ensure_storage() - self._dict[key] = value - self._save(key) - - def _save(self, key): - with open(os.path.join(self._storage_path, key), "w") as f: - f.write(self._dict[key]) - - -value_cache = AndroidValueCache() - - -def send_whatsapp_message(msg): - share_by_intent(message=msg, app="com.whatsapp") - - -def share_by_intent(path=None, filename=None, message=None, app=None, mimetype=None): - - assert ( - path or message or filename - ), "Must provide either a path, a filename, or a msg to share" - AndroidString = autoclass("java.lang.String") - Context = autoclass("android.content.Context") - File = autoclass("java.io.File") - FileProvider = autoclass("android.support.v4.content.FileProvider") - Intent = autoclass("android.content.Intent") - - sendIntent = Intent() - sendIntent.setAction(Intent.ACTION_SEND) - if path: - uri = FileProvider.getUriForFile( - Context.getApplicationContext(), - "org.learningequality.Kolibri.fileprovider", - File(path), - ) - parcelable = cast("android.os.Parcelable", uri) - sendIntent.putExtra(Intent.EXTRA_STREAM, parcelable) - sendIntent.setType(AndroidString(mimetype or "*/*")) - sendIntent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) - if message: - if not path: - sendIntent.setType(AndroidString(mimetype or "text/plain")) - sendIntent.putExtra(Intent.EXTRA_TEXT, AndroidString(message)) - if app: - sendIntent.setPackage(AndroidString(app)) - sendIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - 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] - cert = x509.load_der_x509_certificate( - signature.toByteArray().tostring(), default_backend() - ) - - return cert.issuer.rfc4514_string() - - -def get_signature_key_issuing_organization(): - cache_key = "SIGNATURE_KEY_ORG" - value = value_cache.get(cache_key) - if value is None: - signer = get_signature_key_issuer() - orgs = re.findall(r"\bO=([^,]+)", signer) - value = orgs[0] if orgs else "" - value_cache.set(cache_key, value) - else: - print("Using cached value for issuing org") - return value - - -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() - value = get_string("Learner", currentLocale) - value_cache.set(cache_key, value) - return value - - -def get_os_user_auth_token(): - cache_key = "OS_USER_AUTH_TOKEN" - value = value_cache.get(cache_key) - if value is None: - value = uuid4().hex - value_cache.set(cache_key, value) - return value - - -def os_user(auth_token): - if auth_token == get_os_user_auth_token(): - return (get_dummy_user_name(), True) - return None, False - - -def is_active_network_metered(): - ConnectivityManager = autoclass("android.net.ConnectivityManager") - - return cast( - ConnectivityManager, - get_context().getSystemService(get_context().CONNECTIVITY_SERVICE), - ).isActiveNetworkMetered() diff --git a/src/i18n.py b/src/i18n.py deleted file mode 100644 index dc8fe449..00000000 --- a/src/i18n.py +++ /dev/null @@ -1,11 +0,0 @@ -try: - from strings import i18n_strings -except ImportError: - i18n_strings = {} - - -def get_string(name, language): - try: - return i18n_strings[language][name] - except KeyError: - return name diff --git a/src/initialization.py b/src/initialization.py deleted file mode 100644 index 52ada1f8..00000000 --- a/src/initialization.py +++ /dev/null @@ -1,50 +0,0 @@ -import os -import re -import sys - -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_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) - -os.environ["KOLIBRI_HOME"] = get_home_folder() -os.environ["KOLIBRI_APK_VERSION_NAME"] = get_version_name() -os.environ["DJANGO_SETTINGS_MODULE"] = "kolibri_app_settings" -# Disable restart hooks, as the default restart hook will crash the app. -os.environ["KOLIBRI_RESTART_HOOKS"] = "" -signing_org = get_signature_key_issuing_organization() -if signing_org == "Learning Equality": - runmode = "android-testing" -elif signing_org == "Android": - runmode = "android-debug" -elif signing_org == "Google Inc.": - runmode = "" # Play Store! -else: - runmode = "android-" + re.sub(r"[^a-z ]", "", signing_org.lower()).replace(" ", "-") -os.environ["KOLIBRI_RUN_MODE"] = runmode - -os.environ["TZ"] = get_timezone_name() -os.environ["LC_ALL"] = "en_US.UTF-8" - - -os.environ["KOLIBRI_CHERRYPY_THREAD_POOL"] = "2" - - -def set_node_id(): - Secure = autoclass("android.provider.Settings$Secure") - node_id = Secure.getString(get_context().getContentResolver(), Secure.ANDROID_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. - if node_id and len(node_id) >= 16 and node_id != "9774d56d682e549c": - os.environ["MORANGO_NODE_ID"] = node_id - - -set_node_id() diff --git a/src/kolibri_app_settings.py b/src/kolibri_app_settings.py deleted file mode 100644 index 149f51d2..00000000 --- a/src/kolibri_app_settings.py +++ /dev/null @@ -1,8 +0,0 @@ -from __future__ import absolute_import -from __future__ import print_function -from __future__ import unicode_literals - -from kolibri.deployment.default.settings.base import * # noqa E402 - -SESSION_EXPIRE_AT_BROWSER_CLOSE = False -SESSION_COOKIE_AGE = 52560000 diff --git a/src/monkey_patch_zeroconf.py b/src/monkey_patch_zeroconf.py deleted file mode 100644 index a224e623..00000000 --- a/src/monkey_patch_zeroconf.py +++ /dev/null @@ -1,11 +0,0 @@ -import zeroconf -from jnius import autoclass - -NetworkUtils = autoclass("org.learningequality.NetworkUtils") - - -def get_all_addresses(): - return list(NetworkUtils.getActiveIPv4Addresses()) - - -zeroconf.get_all_addresses = get_all_addresses diff --git a/src/remoteshell.py b/src/remoteshell.py deleted file mode 100644 index 3053bc81..00000000 --- a/src/remoteshell.py +++ /dev/null @@ -1,116 +0,0 @@ -import os - -import initialization # noqa: F401 keep this first, to ensure we're set up for other imports -from cryptography.hazmat.backends import default_backend -from cryptography.hazmat.primitives import serialization -from cryptography.hazmat.primitives.asymmetric import rsa -from kolibri.main import initialize -from twisted.conch import manhole -from twisted.conch import manhole_ssh -from twisted.conch.ssh import keys -from twisted.cred import checkers -from twisted.cred import credentials -from twisted.cred import error -from twisted.cred import portal -from twisted.internet import defer -from twisted.internet import reactor -from zope.interface import implementer - - -def get_key_pair(refresh=False): - - # calculate paths where we'll store our SSH server keys - KEYPATH = os.path.join(os.environ.get("KOLIBRI_HOME", "."), "ssh_host_key") - PUBKEYPATH = KEYPATH + ".pub" - - # check whether we already have keys there, and use them if so - if os.path.isfile(KEYPATH) and os.path.isfile(PUBKEYPATH) and not refresh: - with open(KEYPATH) as f, open(PUBKEYPATH) as pf: - return f.read(), pf.read() - - # otherwise, generate a new key pair and serialize it - key = rsa.generate_private_key( - backend=default_backend(), public_exponent=65537, key_size=2048 - ) - private_key = key.private_bytes( - serialization.Encoding.PEM, - serialization.PrivateFormat.TraditionalOpenSSL, - serialization.NoEncryption(), - ).decode() - public_key = ( - key.public_key() - .public_bytes( - serialization.Encoding.OpenSSH, serialization.PublicFormat.OpenSSH - ) - .decode() - ) - - # store the keys to disk for use again later - with open(KEYPATH, "w") as f, open(PUBKEYPATH, "w") as pf: - f.write(private_key) - pf.write(public_key) - - return private_key, public_key - - -@implementer(checkers.ICredentialsChecker) -class KolibriSuperAdminCredentialsChecker(object): - """ - Check that the device is unprovisioned, or the credentials are for a super admin, - or the password matches the temp password set over ADB. - """ - - credentialInterfaces = (credentials.IUsernamePassword,) - - def requestAvatarId(self, creds): - from kolibri.core.auth.models import FacilityUser - - # if a temporary password was set over ADB, allow login with it - TEMP_ADMIN_PASS_PATH = os.path.join( - os.environ.get("KOLIBRI_HOME", "."), "temp_admin_pass" - ) - if os.path.isfile(TEMP_ADMIN_PASS_PATH): - with open(TEMP_ADMIN_PASS_PATH) as f: - provided_password = creds.password.decode() - if provided_password and provided_password == f.read().strip(): - return creds.username - - # if there are no users yet (not yet provisioned), allow anon - if FacilityUser.objects.count() == 0: - return creds.username - - # check whether there are any super admins with these credentials - users = FacilityUser.objects.filter(username=creds.username) - for user in users: - if user.is_superuser and user.check_password(creds.password): - return creds.username - - # no matching users were found, so fail - return defer.fail(error.UnauthorizedLogin()) - - -def _get_manhole_factory(namespace): - - # ensure django has been set up so we can use the ORM etc in the shell - initialize(skip_update=True) - - # set up the twisted manhole with Kolibri-based authentication - def get_manhole(_): - return manhole.Manhole(namespace) - - realm = manhole_ssh.TerminalRealm() - realm.chainedProtocolFactory.protocolFactory = get_manhole - p = portal.Portal(realm) - p.registerChecker(KolibriSuperAdminCredentialsChecker()) - f = manhole_ssh.ConchFactory(p) - - # get the SSH server key pair to use - private_rsa, public_rsa = get_key_pair() - f.publicKeys[b"ssh-rsa"] = keys.Key.fromString(public_rsa) - f.privateKeys[b"ssh-rsa"] = keys.Key.fromString(private_rsa) - - return f - - -reactor.listenTCP(4242, _get_manhole_factory(globals())) -reactor.run() diff --git a/src/runnable.py b/src/runnable.py deleted file mode 100644 index 8552c480..00000000 --- a/src/runnable.py +++ /dev/null @@ -1,41 +0,0 @@ -""" -Runnable -======== - -""" -from jnius import autoclass -from jnius import java_method -from jnius import PythonJavaClass - -# reference to the activity -_PythonActivity = autoclass("org.kivy.android.PythonActivity") - - -class Runnable(PythonJavaClass): - """Wrapper around Java Runnable class. This class can be used to schedule a - call of a Python function into the PythonActivity thread. - """ - - __javainterfaces__ = ["java/lang/Runnable"] - __runnables__ = [] - - def __init__(self, func): - super(Runnable, self).__init__() - self.func = func - - def __call__(self, *args, **kwargs): - self.args = args - self.kwargs = kwargs - Runnable.__runnables__.append(self) - _PythonActivity.mActivity.runOnUiThread(self) - - @java_method("()V") - def run(self): - try: - self.func(*self.args, **self.kwargs) - except Exception: - import traceback - - traceback.print_exc() - - Runnable.__runnables__.remove(self) From 805bf5d2ffcc84cbcf0ed64a8516c0d53fcbabf1 Mon Sep 17 00:00:00 2001 From: Richard Tibbles Date: Fri, 20 Feb 2026 18:29:59 -0800 Subject: [PATCH 2/7] Add Gradle project with Chaquopy-based Android app Replace the python-for-android/kivy build system with a standard Gradle project using Chaquopy for Python integration. This is the core of the architecture migration. Build system: - Gradle wrapper (8.11.1), root and app build files - Chaquopy plugin for embedding Python in the Android app - Updated .gitignore, requirements.txt, pyproject.toml Java application layer: - App, WebViewActivity, KolibriWebChromeClient for WebView-based UI - KolibriServerService/ViewModel for managing the Kolibri HTTP server - KolibriEnvironmentSetup for Python environment configuration - WorkController/WorkControllerService for background task orchestration - Worker classes (BaseTaskWorker, ForegroundWorker, BackgroundWorker) - Task management (Task, TaskWorkerImpl, WorkerImpl, Observable/Observer) - Notification system (Builder, Manager, NotificationRef, Notifier) - Utility classes (AuthUtils, ContextUtil, NetworkUtils, ShareUtils) Python application layer: - main.py entry point for Kolibri server lifecycle - taskworker.py and task_reconciler.py for background task execution - auth.py for authentication token generation - Kolibri plugin and settings modules - monkey_patch_zeroconf.py for Android compatibility Android resources: - WebView layout with animated splash screen - Kolibri logo vector drawable with wing-flap animation - Notification icons, themes, colors - Network security config, file provider paths - Localized HTML content for loading/error screens Co-Authored-By: Claude Opus 4.6 --- .gitignore | 86 +-- app/build.gradle | 310 ++++++++++ app/src/main/AndroidManifest.xml | 121 ++++ .../org/learningequality/Kolibri/App.java | 226 ++++---- .../Kolibri/KolibriEnvironmentSetup.java | 221 +++++++ .../Kolibri/KolibriServerService.java | 146 +++++ .../Kolibri/KolibriServerViewModel.java | 56 ++ .../Kolibri/KolibriWebChromeClient.java | 101 ++++ .../Kolibri/WebViewActivity.java | 278 +++++++++ .../Kolibri/WorkController.java | 279 +++++---- .../Kolibri/WorkControllerService.java | 542 ++++++++++-------- .../Kolibri/notification/Builder.java | 95 ++- .../Kolibri/notification/Manager.java | 144 +++-- .../Kolibri/notification/NotificationRef.java | 91 ++- .../Kolibri/notification/Notifier.java | 52 +- .../Kolibri/task/Observable.java | 14 +- .../Kolibri/task/Observer.java | 8 +- .../learningequality/Kolibri/task/Task.java | 298 ++++------ .../Kolibri/task/TaskWorkerImpl.java | 182 +++--- .../Kolibri/task/WorkerImpl.java | 9 + .../Kolibri/util/AuthUtils.java | 106 ++++ .../Kolibri/util/ContextUtil.java | 51 +- .../Kolibri/util/NetworkUtils.java | 77 +-- .../Kolibri/util/ShareUtils.java | 59 ++ .../Kolibri/workers/BackgroundWorker.java | 35 +- .../Kolibri/workers/BaseTaskWorker.java | 126 ++++ .../Kolibri/workers/ForegroundWorker.java | 107 ++-- .../python/android_app_plugin/__init__.py | 0 .../android_app_plugin/kolibri_plugin.py | 115 ++++ app/src/main/python/auth.py | 23 + app/src/main/python/kolibri_app_settings.py | 8 + app/src/main/python/main.py | 177 ++++-- app/src/main/python/monkey_patch_zeroconf.py | 9 +- app/src/main/python/task_reconciler.py | 217 +++++++ app/src/main/python/taskworker.py | 49 +- app/src/main/res/animator/wing_flap_inner.xml | 12 + .../main/res/animator/wing_flap_middle.xml | 12 + app/src/main/res/animator/wing_flap_outer.xml | 12 + .../ic_stat_kolibri_notification.png | Bin 0 -> 1354 bytes .../ic_stat_kolibri_notification.png | Bin 0 -> 785 bytes .../ic_stat_kolibri_notification.png | Bin 0 -> 2028 bytes .../ic_stat_kolibri_notification.png | Bin 0 -> 3608 bytes .../ic_stat_kolibri_notification.png | Bin 0 -> 5421 bytes app/src/main/res/drawable/.gitkeep | 0 .../baseline_notifications_paused_24.xml | 5 + app/src/main/res/drawable/kolibri_logo.xml | 85 +++ .../res/drawable/kolibri_logo_animated.xml | 25 + app/src/main/res/layout/activity_webview.xml | 50 ++ app/src/main/res/mipmap-anydpi-v26/.gitkeep | 0 app/src/main/res/mipmap/.gitkeep | 0 app/src/main/res/mipmap/icon.png | Bin 0 -> 72353 bytes app/src/main/res/values/colors.xml | 5 + app/src/main/res/values/strings.xml | 13 + app/src/main/res/values/themes.xml | 24 + app/src/main/res/xml/.gitkeep | 0 app/src/main/res/xml/file_paths.xml | 5 + .../main/res/xml/network_security_config.xml | 8 + build.gradle | 20 + gradle.properties | 12 + gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 89241 bytes gradle/wrapper/gradle-wrapper.properties | 7 + gradlew | 250 ++++++++ pyproject.toml | 37 ++ requirements.txt | 13 +- settings.gradle | 19 + uv.lock | 231 ++++++++ 66 files changed, 4065 insertions(+), 1198 deletions(-) create mode 100644 app/build.gradle create mode 100644 app/src/main/AndroidManifest.xml create mode 100644 app/src/main/java/org/learningequality/Kolibri/KolibriEnvironmentSetup.java create mode 100644 app/src/main/java/org/learningequality/Kolibri/KolibriServerService.java create mode 100644 app/src/main/java/org/learningequality/Kolibri/KolibriServerViewModel.java create mode 100644 app/src/main/java/org/learningequality/Kolibri/KolibriWebChromeClient.java create mode 100644 app/src/main/java/org/learningequality/Kolibri/WebViewActivity.java create mode 100644 app/src/main/java/org/learningequality/Kolibri/task/WorkerImpl.java create mode 100644 app/src/main/java/org/learningequality/Kolibri/util/AuthUtils.java create mode 100644 app/src/main/java/org/learningequality/Kolibri/util/ShareUtils.java create mode 100644 app/src/main/java/org/learningequality/Kolibri/workers/BaseTaskWorker.java create mode 100644 app/src/main/python/android_app_plugin/__init__.py create mode 100644 app/src/main/python/android_app_plugin/kolibri_plugin.py create mode 100644 app/src/main/python/auth.py create mode 100644 app/src/main/python/kolibri_app_settings.py create mode 100644 app/src/main/python/task_reconciler.py create mode 100644 app/src/main/res/animator/wing_flap_inner.xml create mode 100644 app/src/main/res/animator/wing_flap_middle.xml create mode 100644 app/src/main/res/animator/wing_flap_outer.xml create mode 100644 app/src/main/res/drawable-hdpi/ic_stat_kolibri_notification.png create mode 100644 app/src/main/res/drawable-mdpi/ic_stat_kolibri_notification.png create mode 100644 app/src/main/res/drawable-xhdpi/ic_stat_kolibri_notification.png create mode 100644 app/src/main/res/drawable-xxhdpi/ic_stat_kolibri_notification.png create mode 100644 app/src/main/res/drawable-xxxhdpi/ic_stat_kolibri_notification.png create mode 100644 app/src/main/res/drawable/.gitkeep create mode 100644 app/src/main/res/drawable/baseline_notifications_paused_24.xml create mode 100644 app/src/main/res/drawable/kolibri_logo.xml create mode 100644 app/src/main/res/drawable/kolibri_logo_animated.xml create mode 100644 app/src/main/res/layout/activity_webview.xml create mode 100644 app/src/main/res/mipmap-anydpi-v26/.gitkeep create mode 100644 app/src/main/res/mipmap/.gitkeep create mode 100644 app/src/main/res/mipmap/icon.png create mode 100644 app/src/main/res/values/colors.xml create mode 100644 app/src/main/res/values/strings.xml create mode 100644 app/src/main/res/values/themes.xml create mode 100644 app/src/main/res/xml/.gitkeep create mode 100644 app/src/main/res/xml/file_paths.xml create mode 100644 app/src/main/res/xml/network_security_config.xml create mode 100644 build.gradle create mode 100644 gradle.properties create mode 100644 gradle/wrapper/gradle-wrapper.jar create mode 100644 gradle/wrapper/gradle-wrapper.properties create mode 100755 gradlew create mode 100644 pyproject.toml create mode 100644 settings.gradle create mode 100644 uv.lock diff --git a/.gitignore b/.gitignore index 5a28b1ed..8e7feba1 100644 --- a/.gitignore +++ b/.gitignore @@ -1,44 +1,64 @@ -# A generated source file -src/strings.py +# Generated source files +app/src/main/python/strings.py +app/src/main/res/values-*/strings.xml + +# Temporary files tmpenv -tar -python-for-android/build/ -python-for-android/packages/** -python-for-android/dists/kolibri/_python* -python-for-android/dists/kolibri/.gradle -python-for-android/dists/kolibri/build -python-for-android/dists/kolibri/gradle/* -python-for-android/dists/kolibri/jni -python-for-android/dists/kolibri/libs -python-for-android/dists/kolibri/templates -python-for-android/dists/kolibri/obj -python-for-android/dists/kolibri/src/res_initial -python-for-android/dists/kolibri/src/main/assets/private.tar -python-for-android/dists/kolibri/webview_includes -python-for-android/dists/kolibri/*.* -python-for-android/dists/kolibri/gradlew -python-for-android/dists/kolibri/src/main/res/*/html_content.xml -python-for-android/dists/kolibri/src/main/res/values-*/strings.xml -!python-for-android/dists/kolibri/build.gradle -!python-for-android/dists/kolibri/gradle.properties - -# File format for signing key +tmphome/ + +# Kolibri tar files and extracted source +tar/ +tar/extracted/ + +# File format for signing keys *.jks +*.keystore -# output folder +# Output folder dist/ +# Version code file +.version-code + +# Python __pycache__ *.pyc -build_docker -bin/ -build.log -tmphome/ -android_root/ +# Android build artifacts +android_root/ *.apk +bin/ +build/ +.gradle/ +.cxx +.externalNativeBuild +captures/ +local.properties + +# IDE +.idea/ +.vscode/ +*.iml +.DS_Store + +# Claude Code sandbox artifacts +.bash_profile +.bashrc +.profile +.zprofile +.zshrc +.gitconfig +.ripgreprc +.mcp.json +.claude/ +HEAD +config +hooks/ +objects/ +refs/ + +# Environment .env -.version-code -*.keystore .envrc -.idea/ +build_docker +build.log diff --git a/app/build.gradle b/app/build.gradle new file mode 100644 index 00000000..01bb744a --- /dev/null +++ b/app/build.gradle @@ -0,0 +1,310 @@ +plugins { + id 'com.android.application' + id 'com.chaquo.python' + id 'com.diffplug.spotless' +} + +spotless { + java { + target 'src/*/java/**/*.java' + googleJavaFormat('1.19.2') + removeUnusedImports() + trimTrailingWhitespace() + endWithNewline() + } + groovyGradle { + target '*.gradle' + greclipse() + trimTrailingWhitespace() + endWithNewline() + } +} + +import org.gradle.api.tasks.* +import javax.inject.Inject + +// Task to extract Kolibri tar file, stripping unnecessary packages +abstract class StreamlineKolibriTask extends DefaultTask { + @InputFile + abstract RegularFileProperty getTarFile() + + @OutputDirectory + abstract DirectoryProperty getExtractedDir() + + @Inject + abstract ExecOperations getExecOperations() + + /** Extract Kolibri tar, cleaning the output directory and stripping unnecessary packages. */ + static void extract(File tarFile, File extractedDir, def execOps) { + println "Extracting Kolibri from ${tarFile.name}" + + if (extractedDir.exists()) { + extractedDir.deleteDir() + } + extractedDir.mkdirs() + + execOps.exec { + commandLine 'tar', 'xzf', tarFile.absolutePath, + '--exclude=kolibri/dist/cext/*', + '--exclude=kolibri/dist/ifaddr*', + '--directory=' + extractedDir.absolutePath, + '--strip-components=1' + } + + println "Kolibri extracted successfully at ${extractedDir.absolutePath}" + } + + @TaskAction + void streamlineKolibri() { + def tarFile = getTarFile().get().asFile + if (!tarFile.exists()) { + throw new GradleException("Kolibri tar file not found: ${tarFile}") + } + extract(tarFile, getExtractedDir().get().asFile, execOperations) + } +} + +tasks.register('streamlineKolibriTar', StreamlineKolibriTask) { + def tarFile = file('../tar').listFiles()?.find { it.name.endsWith('.tar.gz') && it.name.startsWith('kolibri') } + getTarFile().set(tarFile) + getExtractedDir().set(file('../tar/extracted')) +} + +// Determine which Python to use for build scripts +def buildPythonExecutable = 'python3' // Default +def venvPython = file('../.venv/bin/python3') +if (venvPython.exists()) { + buildPythonExecutable = venvPython.absolutePath +} + +// Ensure Kolibri tar is extracted during configuration phase +// This is necessary so version can be calculated before tasks run +def extractedKolibriDir = file('../tar/extracted') +def tarFile = file('../tar').listFiles()?.find { it.name.endsWith('.tar.gz') && it.name.startsWith('kolibri') } + +if (tarFile != null && tarFile.exists() && !new File(extractedKolibriDir, 'kolibri/VERSION').exists()) { + StreamlineKolibriTask.extract(tarFile, extractedKolibriDir, this) +} + +// Calculate version name and code +def calculatedVersionName +def calculatedVersionCode + +if (extractedKolibriDir.exists() && extractedKolibriDir.isDirectory()) { + // Read VERSION_NAME from Kolibri's VERSION file + def kolibriVersionFile = new File(extractedKolibriDir, 'kolibri/VERSION') + if (!kolibriVersionFile.exists()) { + throw new GradleException("Kolibri VERSION file not found at: ${kolibriVersionFile}") + } + + def kolibriVersion = kolibriVersionFile.text.trim() + def androidInstallerVersion = "0.2.0" + def buildType = System.getenv("RELEASE_KEYALIAS") == "LE_RELEASE_KEY" ? "official" : + System.getenv("RELEASE_KEYALIAS") == "LE_DEV_KEY" ? "dev" : "debug" + calculatedVersionName = "${kolibriVersion}-${androidInstallerVersion}-${buildType}" + + // Get VERSION_CODE + if (buildType == "official") { + // Production builds: MUST use Python script to query Play Store API + println "Official build: getting version code from Play Store API..." + def output = new ByteArrayOutputStream() + exec { + workingDir projectDir.parent + commandLine 'bash', '-c', "${buildPythonExecutable} scripts/version.py" + standardOutput = output + } + calculatedVersionCode = output.toString().trim() as int + } else { + // Dev/debug builds: use timestamp-based version code + def buildBaseNumber = 2008998000L + def now = new Date() + def timestamp = now.format('yyMMddHHmm') as long + calculatedVersionCode = (timestamp - buildBaseNumber) as int + } + + println "Generated version: ${calculatedVersionName} (code: ${calculatedVersionCode})" + + // Write version code to file for CI pipeline consumption + new File(projectDir.parent, '.version-code').text = "${calculatedVersionCode}" +} else { + // Patched Kolibri doesn't exist yet - use defaults for initial Gradle sync + calculatedVersionName = '0.0.1-dev' + calculatedVersionCode = 1 + println "Using default version (build Kolibri tar first for correct version)" +} + +// Task to generate Android resource strings from Kolibri translations +abstract class GenerateKolibriStringsTask extends DefaultTask { + @InputFile + abstract RegularFileProperty getScriptFile() + + @Input + abstract Property getPythonExecutable() + + @OutputDirectory + abstract DirectoryProperty getResDir() + + @Inject + abstract ExecOperations getExecOperations() + + @TaskAction + void generateStrings() { + println "Generating Kolibri strings..." + + def projectRoot = getScriptFile().get().asFile.parentFile.parentFile + def pythonExe = getPythonExecutable().get() + + execOperations.exec { + workingDir projectRoot + commandLine pythonExe, 'scripts/create_strings.py' + } + + println "Kolibri strings generated successfully" + } +} + +tasks.register('generateKolibriStrings', GenerateKolibriStringsTask) { + dependsOn streamlineKolibriTar + getScriptFile().set(file('../scripts/create_strings.py')) + getResDir().set(file('src/main/res')) + getPythonExecutable().set(buildPythonExecutable) +} + +// Make sure Kolibri is extracted before Python requirements are generated +afterEvaluate { + tasks.matching { task -> + task.name.contains('PythonRequirements') || + task.name.contains('extractPythonBuildPackages') + }.all { task -> + task.dependsOn streamlineKolibriTar + } + + // Generate strings before resources are processed or mapped + tasks.matching { task -> + task.name.contains('generateDebugResources') || + task.name.contains('generateReleaseResources') || + task.name.contains('mergeDebugResources') || + task.name.contains('mergeReleaseResources') || + task.name.contains('mapDebugSourceSetPaths') || + task.name.contains('mapReleaseSourceSetPaths') || + task.name.contains('extractDeepLinksDebug') || + task.name.contains('extractDeepLinksRelease') + }.all { task -> + task.dependsOn generateKolibriStrings + } +} + +android { + namespace = 'org.learningequality.Kolibri' + compileSdk = 35 + buildToolsVersion = "35.0.0" + + // Use calculated version from configuration phase + def code = calculatedVersionCode + def name = calculatedVersionName + + // Strip -debug-debug for debug builds + def nameNoDebug = name.replace("-debug", "") + + defaultConfig { + applicationId = "org.learningequality.Kolibri" + minSdk = 24 // Chaquopy requires API 24+ + targetSdk = 35 + versionCode = code + versionName = name + multiDexEnabled = true + base.archivesName = "kolibri-$nameNoDebug" + + ndk { + // ARM for mobile devices, x86_64 for Chromebooks and emulators + // x86 (32-bit) dropped - legacy emulators only + abiFilters "armeabi-v7a", "arm64-v8a", "x86_64" + } + + python { + version = "3.10" + + // Use the same Python as our build scripts (venv if available, else system) + buildPython buildPythonExecutable + + pip { + // Install Kolibri from patched tar file + // The streamlineKolibriTar task extracts the tar before this runs + install "../tar/extracted" + install "-r", "../requirements.txt" + } + } + } + + packagingOptions { + jniLibs { + useLegacyPackaging = true + } + resources { + excludes += [ + 'lib/**/gdbserver', + 'lib/**/gdb.setup' + ] + } + } + + signingConfigs { + release { + storeFile file(System.getenv("RELEASE_KEYSTORE") ?: "empty") + keyAlias System.getenv("RELEASE_KEYALIAS") ?: "" + storePassword System.getenv("RELEASE_KEYSTORE_PASSWD") ?: "" + keyPassword System.getenv("RELEASE_KEYALIAS_PASSWD") ?: "" + } + } + + buildTypes { + debug { + debuggable = true + } + release { + signingConfig = signingConfigs.release + minifyEnabled false + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + + testOptions { + unitTests { + returnDefaultValues = true + } + } + + buildFeatures { + buildConfig = true + } + + androidResources { + noCompress 'tflite' + } + + sourceSets { + main { + python { + srcDir "src/main/python" + } + } + } +} + +dependencies { + implementation 'androidx.appcompat:appcompat:1.7.1' + implementation 'androidx.annotation:annotation:1.9.1' + implementation 'androidx.concurrent:concurrent-futures:1.3.0' + implementation 'androidx.work:work-runtime:2.11.0' + implementation 'androidx.work:work-multiprocess:2.11.0' + implementation 'androidx.lifecycle:lifecycle-service:2.10.0' + implementation 'androidx.multidex:multidex:2.0.1' + + testImplementation 'junit:junit:4.13.2' + testImplementation 'org.robolectric:robolectric:4.16' + testImplementation 'org.mockito:mockito-core:4.11.0' +} diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 00000000..280e3d95 --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,121 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/java/org/learningequality/Kolibri/App.java b/app/src/main/java/org/learningequality/Kolibri/App.java index 266cb93c..1c516577 100644 --- a/app/src/main/java/org/learningequality/Kolibri/App.java +++ b/app/src/main/java/org/learningequality/Kolibri/App.java @@ -5,131 +5,153 @@ import android.content.Context; import android.os.Build; import android.os.Bundle; - import androidx.annotation.NonNull; import androidx.core.app.NotificationChannelCompat; import androidx.core.app.NotificationManagerCompat; import androidx.work.Configuration; - -import org.kivy.android.PythonContext; -import org.learningequality.notification.NotificationRef; - +import com.chaquo.python.Python; +import com.chaquo.python.android.AndroidPlatform; import java.util.concurrent.Executors; import java.util.concurrent.atomic.AtomicInteger; - +import org.learningequality.Kolibri.notification.NotificationRef; public class App extends Application implements Configuration.Provider { - protected final AtomicInteger activeActivities = new AtomicInteger(0); + protected final AtomicInteger activeActivities = new AtomicInteger(0); - @Override - public void onCreate() { - super.onCreate(); - // Initialize Python context - PythonContext.getInstance(this); - createNotificationChannels(); - // Register activity lifecycle callbacks - registerActivityLifecycleCallbacks(new KolibriActivityLifecycleCallbacks()); - WorkController.getInstance(this).wake(); - } + @Override + public void onCreate() { + super.onCreate(); - @NonNull - @Override - public Configuration getWorkManagerConfiguration() { - String processName = getApplicationContext().getPackageName(); - processName += getApplicationContext().getString(R.string.task_worker_process); - - // Using the same quantity of worker threads as Kolibri's python side: - // https://github.com/learningequality/kolibri/blob/release-v0.16.x/kolibri/utils/options.py#L683 - return new Configuration.Builder() - .setDefaultProcessName(processName) - .setMinimumLoggingLevel(android.util.Log.DEBUG) - .setExecutor(Executors.newFixedThreadPool(6)) - .build(); + // Start Python runtime — must happen on main thread before any Python API usage. + // This is fast on subsequent launches (assets already extracted). + if (!Python.isStarted()) { + Python.start(new AndroidPlatform(this)); } - private void createNotificationChannels() { - // Create the NotificationChannel, but only on API 26+ because - // the NotificationChannel class is not in the Support Library. - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - Context context = getApplicationContext(); - NotificationChannelCompat serviceChannel = new NotificationChannelCompat.Builder( - NotificationRef.ID_CHANNEL_SERVICE, - NotificationManagerCompat.IMPORTANCE_MIN - ) - .setName(context.getString(R.string.notification_service_channel_title)) - .setShowBadge(false) - .build(); - NotificationChannelCompat taskChannel = new NotificationChannelCompat.Builder( - NotificationRef.ID_CHANNEL_DEFAULT, - NotificationManagerCompat.IMPORTANCE_DEFAULT - ) - .setName(context.getString(R.string.notification_default_channel_title)) - .build(); - - // Register the channel with the system. You can't change the importance - // or other notification behaviors after this. - NotificationManagerCompat notificationManager = NotificationManagerCompat.from(context); - notificationManager.createNotificationChannel(serviceChannel); - notificationManager.createNotificationChannel(taskChannel); - } + // Initialize Kolibri environment (env vars, migrations) on a background thread to avoid ANR. + // Only run database migrations in the main process to avoid concurrent migration races + // on the shared SQLite database from multiple processes. + boolean skipMigrations = !isMainProcess(); + KolibriEnvironmentSetup.initializeEnvAsync(this, skipMigrations); + + createNotificationChannels(); + // Register activity lifecycle callbacks + registerActivityLifecycleCallbacks(new KolibriActivityLifecycleCallbacks()); + WorkController.getInstance(this).wake(); + } + + @NonNull + @Override + public Configuration getWorkManagerConfiguration() { + String processName = getApplicationContext().getPackageName(); + processName += getApplicationContext().getString(R.string.task_worker_process); + + // Using the same quantity of worker threads as Kolibri's python side: + // https://github.com/learningequality/kolibri/blob/release-v0.16.x/kolibri/utils/options.py#L683 + return new Configuration.Builder() + .setDefaultProcessName(processName) + .setMinimumLoggingLevel(android.util.Log.DEBUG) + .setExecutor(Executors.newFixedThreadPool(6)) + .build(); + } + + private void createNotificationChannels() { + // Create the NotificationChannel, but only on API 26+ because + // the NotificationChannel class is not in the Support Library. + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + Context context = getApplicationContext(); + NotificationChannelCompat serviceChannel = + new NotificationChannelCompat.Builder( + NotificationRef.ID_CHANNEL_SERVICE, NotificationManagerCompat.IMPORTANCE_MIN) + .setName(context.getString(R.string.notification_service_channel_title)) + .setShowBadge(false) + .build(); + NotificationChannelCompat taskChannel = + new NotificationChannelCompat.Builder( + NotificationRef.ID_CHANNEL_DEFAULT, NotificationManagerCompat.IMPORTANCE_DEFAULT) + .setName(context.getString(R.string.notification_default_channel_title)) + .build(); + + // Register the channel with the system. You can't change the importance + // or other notification behaviors after this. + NotificationManagerCompat notificationManager = NotificationManagerCompat.from(context); + notificationManager.createNotificationChannel(serviceChannel); + notificationManager.createNotificationChannel(taskChannel); } - - protected int incrementActiveActivities() { - synchronized (activeActivities) { - return activeActivities.incrementAndGet(); + } + + private boolean isMainProcess() { + String processName; + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + processName = Application.getProcessName(); + } else { + int pid = android.os.Process.myPid(); + android.app.ActivityManager am = + (android.app.ActivityManager) getSystemService(ACTIVITY_SERVICE); + processName = null; + if (am != null) { + for (android.app.ActivityManager.RunningAppProcessInfo info : am.getRunningAppProcesses()) { + if (info.pid == pid) { + processName = info.processName; + break; + } } + } } + return processName != null && processName.equals(getPackageName()); + } - protected int decrementActiveActivities() { - synchronized (activeActivities) { - // Prevent decrementing below 0 - if (activeActivities.get() == 0) { - return 0; - } - return activeActivities.decrementAndGet(); - } + protected int incrementActiveActivities() { + synchronized (activeActivities) { + return activeActivities.incrementAndGet(); + } + } + + protected int decrementActiveActivities() { + synchronized (activeActivities) { + // Prevent decrementing below 0 + if (activeActivities.get() == 0) { + return 0; + } + return activeActivities.decrementAndGet(); } + } - public class KolibriActivityLifecycleCallbacks implements ActivityLifecycleCallbacks { - @Override - public void onActivityCreated(@NonNull Activity activity, Bundle savedInstanceState) { /* no-op */ } + public class KolibriActivityLifecycleCallbacks implements ActivityLifecycleCallbacks { + @Override + public void onActivityCreated(@NonNull Activity activity, Bundle savedInstanceState) {} - @Override - public void onActivityStarted(@NonNull Activity activity) { - incrementActiveActivities(); - WorkController.getInstance(getApplicationContext()).wake(); - } + @Override + public void onActivityStarted(@NonNull Activity activity) { + incrementActiveActivities(); + WorkController.getInstance(getApplicationContext()).wake(); + } - @Override - public void onActivityResumed(@NonNull Activity activity) { - incrementActiveActivities(); - WorkController.getInstance(getApplicationContext()).wake(); - } + @Override + public void onActivityResumed(@NonNull Activity activity) { + // Don't increment - already done in onActivityStarted + WorkController.getInstance(getApplicationContext()).wake(); + } - @Override - public void onActivityPaused(@NonNull Activity activity) { - if (decrementActiveActivities() == 0) { - WorkController.getInstance(getApplicationContext()).sleep(); - } - } + @Override + public void onActivityPaused(@NonNull Activity activity) { + // Don't decrement - wait for onActivityStopped + } - @Override - public void onActivityStopped(@NonNull Activity activity) { /* no-op */ } + @Override + public void onActivityStopped(@NonNull Activity activity) { + if (decrementActiveActivities() == 0) { + WorkController.getInstance(getApplicationContext()).sleep(); + } + } - @Override - public void onActivityPostStopped(@NonNull Activity activity) { - // using postStopped in case another activity is started - if (decrementActiveActivities() == 0) { - WorkController.getInstance(getApplicationContext()).sleep(); - } - } + @Override + public void onActivityPostStopped(@NonNull Activity activity) {} - @Override - public void onActivitySaveInstanceState( - @NonNull Activity activity, @NonNull Bundle outState - ) { /* no-op */ } + @Override + public void onActivitySaveInstanceState(@NonNull Activity activity, @NonNull Bundle outState) {} - @Override - public void onActivityDestroyed(@NonNull Activity activity) { /* no-op */ } - } + @Override + public void onActivityDestroyed(@NonNull Activity activity) {} + } } diff --git a/app/src/main/java/org/learningequality/Kolibri/KolibriEnvironmentSetup.java b/app/src/main/java/org/learningequality/Kolibri/KolibriEnvironmentSetup.java new file mode 100644 index 00000000..2d21e68a --- /dev/null +++ b/app/src/main/java/org/learningequality/Kolibri/KolibriEnvironmentSetup.java @@ -0,0 +1,221 @@ +package org.learningequality.Kolibri; + +import android.content.Context; +import android.provider.Settings; +import android.util.Log; +import com.chaquo.python.PyObject; +import com.chaquo.python.Python; +import com.chaquo.python.android.AndroidPlatform; +import java.io.File; +import java.util.TimeZone; +import java.util.concurrent.CompletableFuture; +import org.learningequality.Kolibri.util.ContextUtil; + +/** Sets up the Kolibri environment including environment variables and Python initialization */ +public class KolibriEnvironmentSetup { + private static final String TAG = "KolibriEnvironmentSetup"; + private static volatile boolean initialized = false; + private static volatile CompletableFuture initFuture; + + // Separate lock for kicking off the async future. Must NOT be the class monitor: + // initializeEnv() is synchronized on the class, so reusing it here would block any + // main-thread caller of initializeEnvAsync() for the full duration of init. + private static final Object asyncInitLock = new Object(); + + /** + * Initialize Kolibri environment asynchronously. Starts Python and sets up all required + * environment variables on a background thread. Callers that need init to complete should call + * {@link #awaitInit()}. + */ + public static void initializeEnvAsync(Context context) { + initializeEnvAsync(context, false); + } + + /** + * Initialize Kolibri environment asynchronously with option to skip database migrations. + * + * @param context Android context + * @param skipUpdate if true, skips database migrations + */ + public static void initializeEnvAsync(Context context, boolean skipUpdate) { + if (initFuture != null) { + return; + } + synchronized (asyncInitLock) { + if (initFuture != null) { + return; + } + initFuture = CompletableFuture.runAsync(() -> initializeEnv(context, skipUpdate)); + } + } + + /** + * Block until environment initialization is complete. Call this from background threads that need + * Python to be ready. + */ + public static void awaitInit() { + CompletableFuture future = initFuture; + if (future != null) { + future.join(); + } + } + + /** Initialize Kolibri environment Starts Python and sets up all required environment variables */ + public static synchronized void initializeEnv(Context context) { + initializeEnv(context, false); + } + + /** + * Initialize Kolibri environment with option to skip database migrations. + * + * @param context Android context + * @param skipUpdate if true, skips database migrations (use in worker processes where the main + * process handles migrations) + */ + public static synchronized void initializeEnv(Context context, boolean skipUpdate) { + if (initialized) { + Log.d(TAG, "Environment already initialized"); + return; + } + + ContextUtil.init(context); + + // Start Python if not already started + if (!Python.isStarted()) { + Log.d(TAG, "Starting Python"); + Python.start(new AndroidPlatform(context)); + } + + // Set environment variables through Python (Chaquopy-compatible approach) + // This ensures Python's os.environ sees the variables + setPythonEnvironmentVariables(context); + + // Initialize Kolibri Python modules + initializeKolibri(skipUpdate); + + initialized = true; + Log.i(TAG, "Kolibri environment initialized successfully"); + } + + /** + * Set environment variables through Python's os.environ This is the Chaquopy-compatible approach + * that ensures Python can see the variables + */ + private static void setPythonEnvironmentVariables(Context context) { + try { + Python py = Python.getInstance(); + PyObject osModule = py.getModule("os"); + PyObject environ = osModule.get("environ"); + + // KOLIBRI_HOME - where Kolibri stores its data + File externalFilesDir = context.getExternalFilesDir(null); + if (externalFilesDir == null) { + // Fallback to internal storage if external storage is unavailable + externalFilesDir = context.getFilesDir(); + Log.w(TAG, "External storage unavailable, using internal storage"); + } + File kolibriHome = new File(externalFilesDir, "KOLIBRI_DATA"); + if (!kolibriHome.exists()) { + if (!kolibriHome.mkdirs()) { + throw new RuntimeException("Failed to create KOLIBRI_HOME directory"); + } + } + // Pre-create logs dir: kolibri.utils.conf's exists()/mkdir() races between processes + File logsDir = new File(kolibriHome, "logs"); + if (!logsDir.exists() && !logsDir.mkdirs()) { + Log.w(TAG, "Failed to pre-create Kolibri logs directory"); + } + environ.callAttr("__setitem__", "KOLIBRI_HOME", kolibriHome.getAbsolutePath()); + + // Version information + String versionName = BuildConfig.VERSION_NAME; + environ.callAttr("__setitem__", "KOLIBRI_APK_VERSION_NAME", versionName); + + // Django settings module + environ.callAttr("__setitem__", "DJANGO_SETTINGS_MODULE", "kolibri_app_settings"); + + // Disable restart hooks (not needed on Android) + environ.callAttr("__setitem__", "KOLIBRI_RESTART_HOOKS", ""); + + // Timezone + TimeZone tz = TimeZone.getDefault(); + environ.callAttr("__setitem__", "TZ", tz.getID()); + + // Locale + environ.callAttr("__setitem__", "LC_CTYPE", "en_US.UTF-8"); + + // Android language + String language = context.getResources().getConfiguration().getLocales().get(0).getLanguage(); + environ.callAttr("__setitem__", "ANDROID_LANG", language); + + // CherryPy thread pool size (keep it small for mobile) + environ.callAttr("__setitem__", "KOLIBRI_CHERRYPY_THREAD_POOL", "2"); + + // Run mode (debug vs release) + if (BuildConfig.DEBUG) { + environ.callAttr("__setitem__", "KOLIBRI_RUN_MODE", "android-debug"); + } else { + environ.callAttr("__setitem__", "KOLIBRI_RUN_MODE", ""); + } + + // Auth token + String authToken = org.learningequality.Kolibri.util.AuthUtils.getOrCreateAuthToken(); + environ.callAttr("__setitem__", "KOLIBRI_AUTH_TOKEN", authToken); + + // Morango node ID (from Android ID) + String androidId = + Settings.Secure.getString(context.getContentResolver(), Settings.Secure.ANDROID_ID); + if (isValidAndroidId(androidId)) { + environ.callAttr("__setitem__", "MORANGO_NODE_ID", androidId); + } + + Log.d(TAG, "Python environment variables set successfully"); + + } catch (Exception e) { + Log.e(TAG, "Error setting Python environment variables", e); + throw new RuntimeException("Failed to set Python environment variables", e); + } + } + + private static boolean isValidAndroidId(String androidId) { + if (androidId == null || androidId.length() < 16) { + return false; + } + // Known bad Android ID on some emulators + if ("9774d56d682e549c".equals(androidId)) { + return false; + } + return true; + } + + private static void initializeKolibri(boolean skipUpdate) { + try { + Python py = Python.getInstance(); + + // Patch zeroconf BEFORE kolibri.main, which binds the unpatched + // get_all_addresses at import time (see monkey_patch_zeroconf.py) + py.getModule("monkey_patch_zeroconf"); + + // Import Kolibri main module + PyObject kolibriMain = py.getModule("kolibri.main"); + + // Enable required plugins BEFORE initialize() + // Plugins must be enabled before the registry is initialized + kolibriMain.callAttr("enable_plugin", "android_app_plugin"); + + // Call initialize. skip_update=True skips database migrations, which should + // only be run by the main process to avoid concurrent migration races. + kolibriMain.callAttr("initialize", skipUpdate); + + Log.d(TAG, "Kolibri Python modules initialized"); + + } catch (Exception e) { + Log.e(TAG, "Error initializing Kolibri Python modules", e); + throw new RuntimeException("Failed to initialize Kolibri", e); + } + } + + public static boolean isInitialized() { + return initialized; + } +} diff --git a/app/src/main/java/org/learningequality/Kolibri/KolibriServerService.java b/app/src/main/java/org/learningequality/Kolibri/KolibriServerService.java new file mode 100644 index 00000000..c7e51db7 --- /dev/null +++ b/app/src/main/java/org/learningequality/Kolibri/KolibriServerService.java @@ -0,0 +1,146 @@ +package org.learningequality.Kolibri; + +import android.app.Service; +import android.content.Context; +import android.content.Intent; +import android.net.wifi.WifiManager; +import android.os.IBinder; +import android.util.Log; +import androidx.annotation.Nullable; +import com.chaquo.python.PyObject; +import com.chaquo.python.Python; + +/** + * Background service that starts the Kolibri HTTP server + * + *

Server runs in background thread and signals readiness via ViewModel. Handles both local + * WebView and remote peer connections. + */ +public class KolibriServerService extends Service { + private static final String TAG = "KolibriServerService"; + private static final String MULTICAST_LOCK_TAG = "kolibri-mdns"; + + private Thread serverThread; + private volatile boolean isRunning = false; + @Nullable private WifiManager.MulticastLock multicastLock; + + @Override + public void onCreate() { + super.onCreate(); + Log.d(TAG, "KolibriServerService onCreate"); + + // Ensure async init has been kicked off (no-op if App already started it) + KolibriEnvironmentSetup.initializeEnvAsync(this); + + // Required for zeroconf to receive mDNS announcements from peers on the local network. + // Without this, the device can announce itself but won't discover others. + acquireMulticastLock(); + } + + @Override + public int onStartCommand(Intent intent, int flags, int startId) { + // Runs on every startService() call; guarded by isRunning + startHttpServer(); + return START_NOT_STICKY; + } + + private void acquireMulticastLock() { + WifiManager wifi = (WifiManager) getApplicationContext().getSystemService(Context.WIFI_SERVICE); + if (wifi == null) { + Log.w(TAG, "WifiManager unavailable; mDNS peer discovery will not work"); + return; + } + multicastLock = wifi.createMulticastLock(MULTICAST_LOCK_TAG); + multicastLock.setReferenceCounted(false); + multicastLock.acquire(); + Log.d(TAG, "Acquired WifiManager multicast lock for mDNS discovery"); + } + + private void releaseMulticastLock() { + if (multicastLock != null && multicastLock.isHeld()) { + multicastLock.release(); + Log.d(TAG, "Released WifiManager multicast lock"); + } + multicastLock = null; + } + + private synchronized void startHttpServer() { + if (isRunning || (serverThread != null && serverThread.isAlive())) { + Log.w(TAG, "Server already running"); + return; + } + + isRunning = true; + serverThread = + new Thread( + () -> { + try { + Log.i(TAG, "Waiting for Kolibri environment initialization"); + KolibriEnvironmentSetup.awaitInit(); + Log.i(TAG, "Starting Kolibri HTTP server"); + + Python py = Python.getInstance(); + PyObject mainModule = py.getModule("main"); + + // This blocks until server stops + mainModule.callAttr("start_server"); + + Log.i(TAG, "Kolibri HTTP server stopped"); + } catch (Exception e) { + Log.e(TAG, "Error running Kolibri HTTP server", e); + } finally { + isRunning = false; + } + }, + "KolibriServerThread"); + + serverThread.start(); + Log.d(TAG, "HTTP server thread started"); + } + + @Override + public void onDestroy() { + super.onDestroy(); + Log.d(TAG, "KolibriServerService onDestroy"); + + isRunning = false; + releaseMulticastLock(); + KolibriServerViewModel.getInstance().resetServerState(); + + // Call Python to stop the server gracefully + try { + Python py = Python.getInstance(); + PyObject mainModule = py.getModule("main"); + mainModule.callAttr("stop_server"); + Log.d(TAG, "Called Python stop_server"); + } catch (Exception e) { + Log.w(TAG, "Error calling stop_server (may already be stopped)", e); + } + + if (serverThread != null && serverThread.isAlive()) { + try { + serverThread.join(5000); + if (serverThread.isAlive()) { + Log.w(TAG, "Server thread did not stop in time, interrupting"); + serverThread.interrupt(); + } + } catch (InterruptedException e) { + Log.w(TAG, "Interrupted waiting for server thread"); + Thread.currentThread().interrupt(); + } + } + + serverThread = null; + } + + @Nullable + @Override + public IBinder onBind(Intent intent) { + // This is a started service, not a bound service + return null; + } + + public boolean isRunning() { + return isRunning; + } +} diff --git a/app/src/main/java/org/learningequality/Kolibri/KolibriServerViewModel.java b/app/src/main/java/org/learningequality/Kolibri/KolibriServerViewModel.java new file mode 100644 index 00000000..1da99c8a --- /dev/null +++ b/app/src/main/java/org/learningequality/Kolibri/KolibriServerViewModel.java @@ -0,0 +1,56 @@ +package org.learningequality.Kolibri; + +import androidx.lifecycle.LiveData; +import androidx.lifecycle.MutableLiveData; + +/** + * Application-level state holder for Kolibri server status. + * + *

This is a singleton because Python (via Chaquopy) needs to call setServerReady() from the + * server thread, and Activities need to observe the state via LiveData. + * + *

Note: This intentionally does NOT extend ViewModel because: 1. It's accessed from Python code + * which can't use ViewModelProvider 2. The state needs to persist across Activity recreation 3. + * It's application-scoped, not Activity-scoped + * + *

LiveData is still used for lifecycle-aware observation in Activities. + */ +public class KolibriServerViewModel { + private static volatile KolibriServerViewModel instance; + private final MutableLiveData serverReady = new MutableLiveData<>(false); + + // Private constructor for singleton + private KolibriServerViewModel() {} + + /** Get the singleton instance. Thread-safe double-checked locking. */ + public static KolibriServerViewModel getInstance() { + if (instance == null) { + synchronized (KolibriServerViewModel.class) { + if (instance == null) { + instance = new KolibriServerViewModel(); + } + } + } + return instance; + } + + /** + * Set server ready state. Called from Python when server starts. Uses postValue() for + * thread-safety (can be called from any thread). + */ + public void setServerReady(boolean ready) { + serverReady.postValue(ready); + } + + /** + * Get LiveData for observing server state. Activities should observe this with their lifecycle. + */ + public LiveData getServerReadyLiveData() { + return serverReady; + } + + /** Reset server state (e.g., when server stops). */ + public void resetServerState() { + serverReady.postValue(false); + } +} diff --git a/app/src/main/java/org/learningequality/Kolibri/KolibriWebChromeClient.java b/app/src/main/java/org/learningequality/Kolibri/KolibriWebChromeClient.java new file mode 100644 index 00000000..e163531b --- /dev/null +++ b/app/src/main/java/org/learningequality/Kolibri/KolibriWebChromeClient.java @@ -0,0 +1,101 @@ +package org.learningequality.Kolibri; + +import android.app.Activity; +import android.os.Build; +import android.view.View; +import android.view.Window; +import android.view.WindowInsets; +import android.view.WindowInsetsController; +import android.webkit.WebChromeClient; +import android.widget.FrameLayout; + +/** Custom WebChromeClient that handles fullscreen video playback */ +public class KolibriWebChromeClient extends WebChromeClient { + private static final String TAG = "Kolibri.WebChromeClient"; + + private final Activity activity; + private final FrameLayout fullscreenContainer; + private View customView; + private CustomViewCallback customViewCallback; + + public KolibriWebChromeClient(Activity activity, FrameLayout fullscreenContainer) { + this.activity = activity; + this.fullscreenContainer = fullscreenContainer; + } + + @Override + public void onShowCustomView(View view, CustomViewCallback callback) { + // If a view already exists, hide it + if (customView != null) { + onHideCustomView(); + return; + } + + // Store the custom view and callback + customView = view; + customViewCallback = callback; + + // Hide system UI for immersive fullscreen + hideSystemUI(); + + // Add the custom view to the fullscreen container + fullscreenContainer.addView(customView); + fullscreenContainer.setVisibility(View.VISIBLE); + } + + @Override + public void onHideCustomView() { + // Remove the custom view + if (customView != null) { + fullscreenContainer.removeView(customView); + customView = null; + } + + // Hide the fullscreen container + fullscreenContainer.setVisibility(View.GONE); + + // Restore system UI + showSystemUI(); + + // Notify the callback + if (customViewCallback != null) { + customViewCallback.onCustomViewHidden(); + customViewCallback = null; + } + } + + private void hideSystemUI() { + Window window = activity.getWindow(); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + WindowInsetsController controller = window.getInsetsController(); + if (controller != null) { + controller.hide(WindowInsets.Type.statusBars() | WindowInsets.Type.navigationBars()); + controller.setSystemBarsBehavior( + WindowInsetsController.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE); + } + } else { + @SuppressWarnings("deprecation") + int flags = + View.SYSTEM_UI_FLAG_FULLSCREEN + | View.SYSTEM_UI_FLAG_HIDE_NAVIGATION + | View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY + | View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION + | View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN; + window.getDecorView().setSystemUiVisibility(flags); + } + } + + private void showSystemUI() { + Window window = activity.getWindow(); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + WindowInsetsController controller = window.getInsetsController(); + if (controller != null) { + controller.show(WindowInsets.Type.statusBars() | WindowInsets.Type.navigationBars()); + } + } else { + @SuppressWarnings("deprecation") + int flags = View.SYSTEM_UI_FLAG_VISIBLE; + window.getDecorView().setSystemUiVisibility(flags); + } + } +} diff --git a/app/src/main/java/org/learningequality/Kolibri/WebViewActivity.java b/app/src/main/java/org/learningequality/Kolibri/WebViewActivity.java new file mode 100644 index 00000000..0899575d --- /dev/null +++ b/app/src/main/java/org/learningequality/Kolibri/WebViewActivity.java @@ -0,0 +1,278 @@ +package org.learningequality.Kolibri; + +import android.Manifest; +import android.content.ActivityNotFoundException; +import android.content.Intent; +import android.content.SharedPreferences; +import android.content.pm.PackageManager; +import android.graphics.drawable.AnimatedVectorDrawable; +import android.graphics.drawable.Drawable; +import android.net.Uri; +import android.os.Build; +import android.os.Bundle; +import android.util.Log; +import android.view.View; +import android.webkit.CookieManager; +import android.webkit.WebResourceRequest; +import android.webkit.WebSettings; +import android.webkit.WebView; +import android.webkit.WebViewClient; +import android.widget.FrameLayout; +import android.widget.ImageView; +import android.widget.TextView; +import androidx.activity.OnBackPressedCallback; +import androidx.appcompat.app.AppCompatActivity; +import androidx.core.app.ActivityCompat; +import androidx.core.content.ContextCompat; +import androidx.core.graphics.Insets; +import androidx.core.view.ViewCompat; +import androidx.core.view.WindowInsetsCompat; +import com.chaquo.python.Python; + +/** + * Main activity that displays Kolibri in a WebView using HTTP + Service Worker + * + *

Waits for the HTTP server, then calls Python to build an initialization URL with auth token. + * Restores the user's last page via the saved path in SharedPreferences. + */ +public class WebViewActivity extends AppCompatActivity { + private static final String TAG = "WebViewActivity"; + private static final int REQUEST_NOTIFICATION_PERMISSION = 1001; + private static final String PREFS_NAME = "kolibri_webview"; + private static final String PREF_LAST_PATH = "last_path"; + private static final String LOCALHOST_HOST = "127.0.0.1"; + + private WebView webView; + private FrameLayout fullscreenContainer; + private View splashContainer; + private boolean shouldClearHistory; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.activity_webview); + + applyEdgeToEdgeInsets(); + requestNotificationPermission(); + setupSplash(); + setupWebView(); + setupBackNavigation(); + loadKolibri(); + } + + @Override + protected void onStart() { + super.onStart(); + // Restart the server service if Android stopped it while the app was idle + startService(new Intent(this, KolibriServerService.class)); + } + + /** + * Apply system-bar + display-cutout insets as padding on the root container. Android 15+ (API + * 35+) enforces edge-to-edge layout when targetSdk>=35, so without this the WebView and splash + * draw under the status/nav bars and the yellow splash color bleeds into those zones. + */ + private void applyEdgeToEdgeInsets() { + View root = findViewById(R.id.root_container); + ViewCompat.setOnApplyWindowInsetsListener( + root, + (v, windowInsets) -> { + Insets insets = + windowInsets.getInsets( + WindowInsetsCompat.Type.systemBars() | WindowInsetsCompat.Type.displayCutout()); + v.setPadding(insets.left, insets.top, insets.right, insets.bottom); + return WindowInsetsCompat.CONSUMED; + }); + } + + /** Request POST_NOTIFICATIONS permission on Android 13+ */ + private void requestNotificationPermission() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + if (ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) + != PackageManager.PERMISSION_GRANTED) { + Log.d(TAG, "Requesting POST_NOTIFICATIONS permission"); + ActivityCompat.requestPermissions( + this, + new String[] {Manifest.permission.POST_NOTIFICATIONS}, + REQUEST_NOTIFICATION_PERMISSION); + } + } + } + + /** Start the animated splash screen and set version text. */ + private void setupSplash() { + splashContainer = findViewById(R.id.splash_container); + + ImageView splashImage = findViewById(R.id.splash_image); + Drawable drawable = splashImage.getDrawable(); + if (drawable instanceof AnimatedVectorDrawable) { + ((AnimatedVectorDrawable) drawable).start(); + } + + TextView versionText = findViewById(R.id.version_text); + versionText.setText(BuildConfig.VERSION_NAME); + } + + /** + * Setup back navigation using OnBackPressedCallback. Handles WebView history navigation before + * exiting activity. + */ + private void setupBackNavigation() { + getOnBackPressedDispatcher() + .addCallback( + this, + new OnBackPressedCallback(true) { + @Override + public void handleOnBackPressed() { + if (webView != null && webView.canGoBack()) { + webView.goBack(); + } else { + // Disable this callback and trigger default back behavior + setEnabled(false); + getOnBackPressedDispatcher().onBackPressed(); + } + } + }); + } + + private void setupWebView() { + if (BuildConfig.DEBUG) { + WebView.setWebContentsDebuggingEnabled(true); + } + + this.webView = findViewById(R.id.webview); + fullscreenContainer = findViewById(R.id.fullscreen_container); + WebSettings settings = webView.getSettings(); + + // Enable cookies and ensure persistence + CookieManager cookieManager = CookieManager.getInstance(); + cookieManager.setAcceptCookie(true); + cookieManager.setAcceptThirdPartyCookies(webView, false); + + // Enable DOM storage (localStorage/sessionStorage) for Kolibri web app + settings.setDomStorageEnabled(true); + settings.setJavaScriptEnabled(true); + + // Local HTTP server only — no mixed content needed + settings.setMixedContentMode(WebSettings.MIXED_CONTENT_NEVER_ALLOW); + + // No file:// or content:// access needed — Kolibri loads via http://127.0.0.1 + settings.setAllowFileAccess(false); + settings.setAllowContentAccess(false); + + // Set WebChromeClient for fullscreen video + webView.setWebChromeClient(new KolibriWebChromeClient(this, fullscreenContainer)); + + // Open external URLs in the system browser, keep local URLs in the WebView + webView.setWebViewClient( + new WebViewClient() { + @Override + public boolean shouldOverrideUrlLoading(WebView view, WebResourceRequest request) { + Uri uri = request.getUrl(); + String host = uri.getHost(); + if (LOCALHOST_HOST.equals(host) || "localhost".equals(host)) { + return false; + } + try { + Intent intent = new Intent(Intent.ACTION_VIEW, uri); + startActivity(intent); + } catch (ActivityNotFoundException e) { + Log.e(TAG, "No activity found to handle URL: " + uri, e); + } + return true; + } + + @Override + public void onPageFinished(WebView view, String url) { + if (shouldClearHistory) { + view.clearHistory(); + shouldClearHistory = false; + } + // Hide splash when a real localhost page finishes loading + if (url != null && !url.startsWith("data:")) { + hideSplash(); + } + saveLastPath(url); + } + }); + } + + private void loadKolibri() { + Log.d(TAG, "Loading Kolibri"); + + // Start server service + Intent serverIntent = new Intent(this, KolibriServerService.class); + startService(serverIntent); + + // Observe server readiness from singleton + // Note: Using getInstance() not ViewModelProvider to ensure we observe + // the same instance that Python updates via setServerReady() + KolibriServerViewModel viewModel = KolibriServerViewModel.getInstance(); + + viewModel + .getServerReadyLiveData() + .observe( + this, + ready -> { + if (ready) { + // Read saved path to restore user's last page + SharedPreferences prefs = getSharedPreferences(PREFS_NAME, MODE_PRIVATE); + String nextUrl = prefs.getString(PREF_LAST_PATH, null); + Log.d(TAG, "Server ready, restoring path: " + nextUrl); + + // Build initialization URL on background thread (calls Python) + new Thread( + () -> { + String url = + Python.getInstance() + .getModule("main") + .callAttr("get_initialize_url", nextUrl) + .toString(); + runOnUiThread( + () -> { + if (webView != null) { + shouldClearHistory = true; + webView.loadUrl(url); + } + }); + }) + .start(); + } + }); + } + + /** + * Save the last loaded path from a localhost URL for restoring on next launch. Skips + * non-localhost URLs and data: URLs. + */ + private void saveLastPath(String url) { + if (url == null || url.startsWith("data:")) { + return; + } + Uri uri = Uri.parse(url); + if (!LOCALHOST_HOST.equals(uri.getHost())) { + return; + } + String origin = uri.getScheme() + "://" + uri.getAuthority(); + String path = url.substring(origin.length()); + getSharedPreferences(PREFS_NAME, MODE_PRIVATE).edit().putString(PREF_LAST_PATH, path).apply(); + } + + /** Hide the splash screen */ + private void hideSplash() { + if (splashContainer != null && splashContainer.getVisibility() == View.VISIBLE) { + splashContainer.setVisibility(View.GONE); + } + } + + @Override + protected void onDestroy() { + super.onDestroy(); + + // Destroy WebView + if (webView != null) { + webView.destroy(); + webView = null; + } + } +} diff --git a/app/src/main/java/org/learningequality/Kolibri/WorkController.java b/app/src/main/java/org/learningequality/Kolibri/WorkController.java index a7f6216c..cea05364 100644 --- a/app/src/main/java/org/learningequality/Kolibri/WorkController.java +++ b/app/src/main/java/org/learningequality/Kolibri/WorkController.java @@ -9,8 +9,7 @@ import android.os.Messenger; import android.os.RemoteException; import android.util.Log; - -import java9.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletableFuture; import java.util.concurrent.atomic.AtomicBoolean; /** @@ -18,165 +17,163 @@ * used to wake, sleep, and stop the service. */ public class WorkController { - public static final String TAG = "Kolibri.WorkController"; - protected static WorkController mInstance; - - private final Context context; - private Connection connection; - private Messenger messenger; - private CompletableFuture connected; - private final AtomicBoolean isConnected = new AtomicBoolean(false); - - private WorkController(Context context) { - this.context = context; - this.connected = new CompletableFuture<>(); - } - - public static WorkController getInstance(Context context) { - // The double checking is a common convention for singletons when synchronizing. + public static final String TAG = "Kolibri.WorkController"; + protected static volatile WorkController mInstance; + + private final Context context; + private Connection connection; + private Messenger messenger; + private CompletableFuture connected; + private final AtomicBoolean isConnected = new AtomicBoolean(false); + + private WorkController(Context context) { + this.context = context.getApplicationContext(); + this.connected = new CompletableFuture<>(); + } + + public static WorkController getInstance(Context context) { + // The double checking is a common convention for singletons when synchronizing. + if (mInstance == null) { + synchronized (WorkController.class) { if (mInstance == null) { - synchronized (WorkController.class) { - if (mInstance == null) { - mInstance = new WorkController(context); - } - } + mInstance = new WorkController(context); } - return mInstance; + } } - - public void wake() { - synchronized (isConnected) { - // If we're already connected, then it's awake - if (isConnected.get()) { - return; - } - } - dispatch(buildMessage(WorkControllerService.Action.WAKE)); - // Always do a reconcile when waking up, and we were previously asleep - dispatch(buildMessage(WorkControllerService.Action.RECONCILE)); + return mInstance; + } + + public void wake() { + synchronized (isConnected) { + // If we're already connected, then it's awake + if (isConnected.get()) { + return; + } } - - public void sleep() { - synchronized (isConnected) { - // If we're not already connected, then it's asleep - if (!isConnected.get()) { - return; - } - } - dispatch(buildMessage(WorkControllerService.Action.SLEEP)); + dispatch(buildMessage(WorkControllerService.Action.WAKE)); + // Always do a reconcile when waking up, and we were previously asleep + dispatch(buildMessage(WorkControllerService.Action.RECONCILE)); + } + + public void sleep() { + synchronized (isConnected) { + // If we're not already connected, then it's asleep + if (!isConnected.get()) { + return; + } } - - public void stop() { - synchronized (isConnected) { - // If we're not already connected, then it's asleep - if (!isConnected.get()) { - return; - } - } - dispatch(buildMessage(WorkControllerService.Action.STOP)); + dispatch(buildMessage(WorkControllerService.Action.SLEEP)); + } + + public void stop() { + synchronized (isConnected) { + // If we're not already connected, then it's asleep + if (!isConnected.get()) { + return; + } } - - public void reconcile() { - synchronized (isConnected) { - // If we're not already connected, then it's asleep - if (!isConnected.get()) { - return; - } - } - dispatch(buildMessage(WorkControllerService.Action.RECONCILE)); + dispatch(buildMessage(WorkControllerService.Action.STOP)); + } + + public void reconcile() { + synchronized (isConnected) { + // If we're not already connected, then it's asleep + if (!isConnected.get()) { + return; + } } - - public void destroy() { - if (connection != null) { - context.unbindService(connection); - connection = null; - messenger = null; - isConnected.set(false); - } - mInstance = null; + dispatch(buildMessage(WorkControllerService.Action.RECONCILE)); + } + + public void destroy() { + if (connection != null) { + context.unbindService(connection); + connection = null; + messenger = null; + isConnected.set(false); } + mInstance = null; + } - protected Message buildMessage(WorkControllerService.Action action) { - return Message.obtain(null, action.getId(), 0, 0); - } + protected Message buildMessage(WorkControllerService.Action action) { + return Message.obtain(null, action.getId(), 0, 0); + } + + protected void dispatch(Message message) { + dispatch(message, 0); + } - protected void dispatch(Message message) { - dispatch(message, 0); + protected void dispatch(Message message, int attempts) { + if (connection == null) { + connection = new Connection(); } - protected void dispatch(Message message, int attempts) { - if (connection == null) { - connection = new Connection(); + synchronized (isConnected) { + // If we're already connected, then it's awake + if (!isConnected.get()) { + if (connected.isDone()) { + connected = new CompletableFuture<>(); } + // Binding allows us to monitor the connection state + context.bindService( + new Intent(context, WorkControllerService.class), connection, Context.BIND_AUTO_CREATE); + } + } - synchronized (isConnected) { - // If we're already connected, then it's awake - if (!isConnected.get()) { - if (connected.isDone()) { - connected = new CompletableFuture<>(); - } - // Binding allows us to monitor the connection state - context.bindService( - new Intent(context, WorkControllerService.class), - connection, - Context.BIND_AUTO_CREATE - ); + connected.thenApply( + (msg) -> { + try { + msg.send(message); + } catch (RemoteException e) { + // If the remote process has died, then we need to rebind + synchronized (isConnected) { + isConnected.set(false); + WorkController.this.messenger = null; } - } - - connected.thenApply((messenger) -> { - try { - messenger.send(message); - } catch (RemoteException e) { - // If the remote process has died, then we need to rebind - synchronized (isConnected) { - isConnected.set(false); - messenger = null; - } - if (attempts < 3) { - dispatch(message, attempts + 1); - } - } catch (Exception e) { - Log.e(TAG, "Failed to send message " + message, e); + if (attempts < 3) { + dispatch(message, attempts + 1); } - return messenger; + } catch (Exception e) { + Log.e(TAG, "Failed to send message " + message, e); + } + return msg; }); + } + + public class Connection implements ServiceConnection { + public void onServiceConnected(ComponentName name, IBinder service) { + Log.d(TAG, "Connected to work manager service"); + synchronized (isConnected) { + isConnected.set(true); + messenger = new Messenger(service); + connected.complete(messenger); + } } - public class Connection implements ServiceConnection { - public void onServiceConnected(ComponentName name, IBinder service) { - Log.d(TAG, "Connected to work manager service"); - synchronized (isConnected) { - isConnected.set(true); - messenger = new Messenger(service); - connected.complete(messenger); - } - } - - public void onServiceDisconnected(ComponentName name) { - Log.d(TAG, "Disconnected from work controller service"); - synchronized (isConnected) { - isConnected.set(false); - messenger = null; - } - } + public void onServiceDisconnected(ComponentName name) { + Log.d(TAG, "Disconnected from work controller service"); + synchronized (isConnected) { + isConnected.set(false); + messenger = null; + } + } - @Override - public void onBindingDied(ComponentName name) { - Log.d(TAG, "Disconnected from work controller service"); - synchronized (isConnected) { - isConnected.set(false); - messenger = null; - } - } + @Override + public void onBindingDied(ComponentName name) { + Log.d(TAG, "Disconnected from work controller service"); + synchronized (isConnected) { + isConnected.set(false); + messenger = null; + } + } - @Override - public void onNullBinding(ComponentName name) { - Log.d(TAG, "Disconnected from work controller service"); - synchronized (isConnected) { - isConnected.set(false); - messenger = null; - } - } + @Override + public void onNullBinding(ComponentName name) { + Log.d(TAG, "Disconnected from work controller service"); + synchronized (isConnected) { + isConnected.set(false); + messenger = null; + } } + } } diff --git a/app/src/main/java/org/learningequality/Kolibri/WorkControllerService.java b/app/src/main/java/org/learningequality/Kolibri/WorkControllerService.java index 08e9b068..20ef2bbe 100644 --- a/app/src/main/java/org/learningequality/Kolibri/WorkControllerService.java +++ b/app/src/main/java/org/learningequality/Kolibri/WorkControllerService.java @@ -6,308 +6,342 @@ import android.content.ServiceConnection; import android.os.Handler; import android.os.IBinder; +import android.os.Looper; import android.os.Message; import android.os.Messenger; import android.util.Log; - import androidx.annotation.Nullable; import androidx.work.multiprocess.RemoteWorkManagerService; - -import org.learningequality.Task; - +import com.chaquo.python.PyObject; +import com.chaquo.python.Python; +import java.util.concurrent.CompletableFuture; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicReference; -import java9.util.concurrent.CompletableFuture; - /** - * This service is responsible for starting the remote work manager service and - * initializing the work manager in the task worker process. + * This service is responsible for starting the remote work manager service and initializing the + * work manager in the task worker process. */ public class WorkControllerService extends Service { - public static final String TAG = "Kolibri.WorkControllerService"; - public static final int ACTION_WAKE = 1; - public static final int ACTION_SLEEP = 2; - public static final int ACTION_STOP = 3; - public static final int ACTION_RECONCILE = 4; - protected static final AtomicReference state = new AtomicReference<>(State.SLEEPING); - protected static final AtomicInteger taskCount = new AtomicInteger(0); - protected final AtomicBoolean isConnected = new AtomicBoolean(false); - protected final AtomicBoolean shouldReconcile = new AtomicBoolean(true); - protected Intent workManagerIntent; - protected ExecutorService executor; - protected CompletableFuture futureChain; - protected WorkControllerHandler messageHandler; - protected Messenger messenger; - private ServiceConnection connection; + public static final String TAG = "Kolibri.WorkControllerService"; + public static final int ACTION_WAKE = 1; + public static final int ACTION_SLEEP = 2; + public static final int ACTION_STOP = 3; + public static final int ACTION_RECONCILE = 4; + protected static final AtomicReference state = new AtomicReference<>(State.SLEEPING); + protected static final AtomicInteger taskCount = new AtomicInteger(0); + protected final AtomicBoolean isConnected = new AtomicBoolean(false); + protected final AtomicBoolean shouldReconcile = new AtomicBoolean(true); + protected Intent workManagerIntent; + protected ExecutorService executor; + protected CompletableFuture futureChain; + protected WorkControllerHandler messageHandler; + protected Messenger messenger; + private ServiceConnection connection; + + @Override + public void onCreate() { + super.onCreate(); + Log.v(TAG, "Initializing work controller service"); + + synchronized (state) { + state.set(State.AWAKE); + } - @Override - public void onCreate() { - Log.v(TAG, "Initializing work controller service"); - - synchronized (state) { - state.set(State.AWAKE); - } - - workManagerIntent = new Intent(this, RemoteWorkManagerService.class); - connection = new WorkManagerConnection(); - executor = Executors.newFixedThreadPool(3); - futureChain = CompletableFuture.completedFuture(null); - messageHandler = new WorkControllerHandler(); - messenger = new Messenger(messageHandler); + // Initialize Python and Kolibri environment asynchronously + KolibriEnvironmentSetup.initializeEnvAsync(this); + + workManagerIntent = new Intent(this, RemoteWorkManagerService.class); + connection = new WorkManagerConnection(); + executor = Executors.newFixedThreadPool(3); + // Seed the future chain with awaitInit so all tasks wait for init to complete + futureChain = CompletableFuture.runAsync(KolibriEnvironmentSetup::awaitInit, executor); + messageHandler = new WorkControllerHandler(); + messenger = new Messenger(messageHandler); + } + + @Override + public void onDestroy() { + super.onDestroy(); + Log.d(TAG, "Destroying work controller service"); + synchronized (state) { + state.set(State.STOPPED); + } + if (isConnected.get()) { + unbindService(connection); + } + executor.shutdown(); + futureChain.cancel(true); + + executor = null; + futureChain = null; + workManagerIntent = null; + messageHandler = null; + messenger = null; + connection = null; + } + + protected void onWake() { + Log.d(TAG, "Waking up work controller service"); + synchronized (state) { + if (state.get() != State.AWAKE_LOW_MEMORY) { + state.set(State.AWAKE); + } } - @Override - public void onDestroy() { - Log.d(TAG, "Destroying work controller service"); - synchronized (state) { - state.set(State.STOPPED); - } - unbindService(connection); - executor.shutdown(); - futureChain.cancel(true); - - executor = null; - futureChain = null; - workManagerIntent = null; - messageHandler = null; - messenger = null; - connection = null; + synchronized (isConnected) { + if (isConnected.get()) { + // Already connected, no need to bind again + return; + } } - protected void onWake() { - Log.d(TAG, "Waking up work controller service"); - synchronized (state) { - if (state.get() != State.AWAKE_LOW_MEMORY) { - state.set(State.AWAKE); - } - } - - synchronized (isConnected) { - if (isConnected.get()) { - // Already connected, no need to bind again - return; - } - } - - startTask(new WorkTask("wake_work_manager") { - @Override - public CompletableFuture run() { - // Wakey wakey remote work manager service - Log.d(TAG, "Binding to work manager service"); - bindService(workManagerIntent, connection, Context.BIND_AUTO_CREATE); - return null; - } + startTask( + new WorkTask("wake_work_manager") { + @Override + public CompletableFuture run() { + // Wakey wakey remote work manager service + Log.d(TAG, "Binding to work manager service"); + bindService(workManagerIntent, connection, Context.BIND_AUTO_CREATE); + return null; + } }); + } + + protected void onReconcile() { + synchronized (shouldReconcile) { + if (!shouldReconcile.get()) { + Log.d(TAG, "Skipping enqueue of task reconciliation"); + return; + } + shouldReconcile.set(false); } - protected void onReconcile() { - synchronized (shouldReconcile) { - if (!shouldReconcile.get()) { - Log.d(TAG, "Skipping enqueue of task reconciliation"); - return; - } - shouldReconcile.set(false); - } - - Log.d(TAG, "Enqueuing task reconciliation"); - startTask(new WorkTask("reconciliation") { - @Override - public CompletableFuture run() { - return Task.reconcile(getApplicationContext(), executor).thenApply((r) -> null); - } + Log.d(TAG, "Enqueuing task reconciliation"); + startTask( + new WorkTask("reconciliation") { + @Override + public CompletableFuture run() { + return CompletableFuture.supplyAsync( + () -> { + try { + Python py = Python.getInstance(); + PyObject reconciler = py.getModule("task_reconciler"); + PyObject result = reconciler.callAttr("reconcile_tasks"); + + // Result is a tuple (added, cancelled) + java.util.List resultList = result.asList(); + int added = resultList.get(0).toInt(); + int cancelled = resultList.get(1).toInt(); + Log.i(TAG, "Reconciliation: added=" + added + ", cancelled=" + cancelled); + } catch (Exception e) { + Log.e(TAG, "Error during reconciliation", e); + } + return null; + }, + executor); + } }); - } + } - protected void onSleep() { - Log.d(TAG, "Sleeping work controller service"); - synchronized (state) { - state.set(State.SLEEPING); - } - synchronized (taskCount) { - if (taskCount.get() == 0) { - Log.d(TAG, "Stopping service due to no more tasks"); - stopSelf(); - } else { - Log.d(TAG, "Waiting for " + taskCount.get() + " tasks to complete"); - } - } - synchronized (shouldReconcile) { - shouldReconcile.set(true); - } + protected void onSleep() { + Log.d(TAG, "Sleeping work controller service"); + synchronized (state) { + state.set(State.SLEEPING); } - - protected void onStop() { - Log.d(TAG, "Stopping work controller service"); - // should eventually call `onDestroy` and that will set the stopped state - synchronized (state) { - state.set(State.STOPPED); - } + synchronized (taskCount) { + if (taskCount.get() == 0) { + Log.d(TAG, "Stopping service due to no more tasks"); stopSelf(); + } else { + Log.d(TAG, "Waiting for " + taskCount.get() + " tasks to complete"); + } + } + synchronized (shouldReconcile) { + shouldReconcile.set(true); } + } - protected void startTask(WorkTask task) { - futureChain = futureChain.thenCompose((nothing) -> { - try { - Log.d(TAG, "Running task: " + task.getName()); - CompletableFuture f = task.run(); - if (f != null) { - return f; - } - } catch (Exception e) { - Log.e(TAG, "Failed running task: " + task.getName(), e); - return CompletableFuture.completedFuture(null); - } - return CompletableFuture.completedFuture(null); - }).thenApply((nothing) -> { - Log.d(TAG, "Task completed: " + task.getName()); - boolean hasNoMoreTasks = false; - synchronized (taskCount) { - if (taskCount.decrementAndGet() == 0) { - Log.d(TAG, "Checking state for stopping service"); - hasNoMoreTasks = true; - } - } - if (hasNoMoreTasks) { - synchronized (state) { - if (state.get() != State.AWAKE) { + protected void onStop() { + Log.d(TAG, "Stopping work controller service"); + // should eventually call `onDestroy` and that will set the stopped state + synchronized (state) { + state.set(State.STOPPED); + } + stopSelf(); + } + + protected void startTask(WorkTask task) { + synchronized (taskCount) { + taskCount.incrementAndGet(); + } + futureChain = + futureChain + .thenCompose( + (nothing) -> { + try { + Log.d(TAG, "Running task: " + task.getName()); + CompletableFuture f = task.run(); + if (f != null) { + return f; + } + } catch (Exception e) { + Log.e(TAG, "Failed running task: " + task.getName(), e); + return CompletableFuture.completedFuture(null); + } + return CompletableFuture.completedFuture(null); + }) + .thenApply( + (nothing) -> { + Log.d(TAG, "Task completed: " + task.getName()); + boolean hasNoMoreTasks = false; + synchronized (taskCount) { + if (taskCount.decrementAndGet() == 0) { + Log.d(TAG, "Checking state for stopping service"); + hasNoMoreTasks = true; + } + } + if (hasNoMoreTasks) { + synchronized (state) { + if (state.get() != State.AWAKE) { Log.d(TAG, "Stopping service due to no more tasks"); stopSelf(); + } } - } - } - return null; - }); + } + return null; + }); + } + + @Override + public void onLowMemory() { + super.onLowMemory(); + Log.d(TAG, "Alerted of low memory"); + synchronized (state) { + state.set(State.AWAKE_LOW_MEMORY); } - - @Override - public void onLowMemory() { - Log.d(TAG, "Alerted of low memory"); - synchronized (state) { - state.set(State.AWAKE_LOW_MEMORY); - } + } + + @Override + public void onTrimMemory(int level) { + super.onTrimMemory(level); + if (level >= TRIM_MEMORY_RUNNING_LOW) { + Log.d(TAG, "Trimming memory (level " + level + "), entering low memory state"); + synchronized (state) { + state.set(State.AWAKE_LOW_MEMORY); + } + } + } + + @Nullable + @Override + public IBinder onBind(Intent intent) { + Log.d(TAG, "Producing binding to work controller service"); + return messenger.getBinder(); + } + + public enum State { + AWAKE, + AWAKE_LOW_MEMORY, + SLEEPING, + STOPPED, + } + + public enum Action { + WAKE(ACTION_WAKE), + SLEEP(ACTION_SLEEP), + STOP(ACTION_STOP), + RECONCILE(ACTION_RECONCILE), + ; + + public final int id; + + Action(int id) { + this.id = id; } - @Override - public void onTrimMemory(int level) { - Log.d(TAG, "Trimming memory, stopping service"); - synchronized (state) { - state.set(State.AWAKE_LOW_MEMORY); - } + public int getId() { + return id; } + } - @Nullable - @Override - public IBinder onBind(Intent intent) { - Log.d(TAG, "Producing binding to work controller service"); - return messenger.getBinder(); + abstract static class WorkTask { + protected final String name; + + public WorkTask(String name) { + this.name = name; } - public enum State { - AWAKE, - AWAKE_LOW_MEMORY, - SLEEPING, - STOPPED, + public String getName() { + return name; } - public enum Action { - WAKE(ACTION_WAKE), - SLEEP(ACTION_SLEEP), - STOP(ACTION_STOP), - RECONCILE(ACTION_RECONCILE), - ; + public abstract CompletableFuture run(); + } - public final int id; + class WorkManagerConnection implements ServiceConnection { + @Override + public void onServiceConnected(android.content.ComponentName name, IBinder service) { + Log.d(TAG, "WorkManager service connected"); + synchronized (isConnected) { + isConnected.set(true); + } + } - Action(int id) { - this.id = id; - } + @Override + public void onServiceDisconnected(android.content.ComponentName name) { + Log.d(TAG, "WorkManager service disconnected"); + synchronized (isConnected) { + isConnected.set(false); + } + } - public int getId() { - return id; - } + @Override + public void onBindingDied(android.content.ComponentName name) { + Log.d(TAG, "WorkManager service binding died"); + synchronized (isConnected) { + isConnected.set(false); + } } - abstract static class WorkTask { - protected final String name; - public WorkTask(String name) { - this.name = name; - } - public String getName() { - return name; - } - abstract public CompletableFuture run(); + @Override + public void onNullBinding(android.content.ComponentName name) { + // WorkManager service should produce a binding normally + Log.d(TAG, "WorkManager service gave null binding"); + synchronized (isConnected) { + isConnected.set(false); + } } + } - class WorkManagerConnection implements ServiceConnection { - @Override - public void onServiceConnected(android.content.ComponentName name, IBinder service) { - Log.d(TAG, "WorkManager service connected"); - synchronized (isConnected) { - isConnected.set(true); - } - } - - @Override - public void onServiceDisconnected(android.content.ComponentName name) { - Log.d(TAG, "WorkManager service disconnected"); - synchronized (isConnected) { - isConnected.set(false); - } - } - - @Override - public void onBindingDied(android.content.ComponentName name) { - Log.d(TAG, "WorkManager service binding died"); - synchronized (isConnected) { - isConnected.set(false); - } - } - - @Override - public void onNullBinding(android.content.ComponentName name) { - // WorkManager service should produce a binding normally - Log.d(TAG, "WorkManager service gave null binding"); - synchronized (isConnected) { - isConnected.set(false); - } - } + class WorkControllerHandler extends Handler { + public WorkControllerHandler() { + super(Looper.getMainLooper()); } - class WorkControllerHandler extends Handler { - public WorkControllerHandler() { - super(); - } - - @Override - public void handleMessage(Message msg) { - Log.d(TAG, "Received message " + msg.what); - synchronized (taskCount) { - taskCount.incrementAndGet(); - } - switch (msg.what) { - case ACTION_WAKE: - onWake(); - break; - case ACTION_RECONCILE: - onReconcile(); - break; - case ACTION_SLEEP: - onSleep(); - break; - case ACTION_STOP: - onStop(); - break; - default: - Log.e(TAG, "Unknown action " + msg.what); - synchronized (taskCount) { - taskCount.decrementAndGet(); - } - break; - } - } + @Override + public void handleMessage(Message msg) { + Log.d(TAG, "Received message " + msg.what); + switch (msg.what) { + case ACTION_WAKE: + onWake(); + break; + case ACTION_RECONCILE: + onReconcile(); + break; + case ACTION_SLEEP: + onSleep(); + break; + case ACTION_STOP: + onStop(); + break; + default: + Log.e(TAG, "Unknown action " + msg.what); + break; + } } + } } diff --git a/app/src/main/java/org/learningequality/Kolibri/notification/Builder.java b/app/src/main/java/org/learningequality/Kolibri/notification/Builder.java index 8fb0409d..8823937d 100644 --- a/app/src/main/java/org/learningequality/Kolibri/notification/Builder.java +++ b/app/src/main/java/org/learningequality/Kolibri/notification/Builder.java @@ -1,65 +1,64 @@ -package org.learningequality.notification; +package org.learningequality.Kolibri.notification; import android.app.PendingIntent; import android.content.Context; import android.content.Intent; import android.provider.Settings; - import androidx.core.app.NotificationCompat; - import org.learningequality.Kolibri.R; public class Builder extends NotificationCompat.Builder { - public Builder(Context context, String channelId) { - super(context, channelId); - setSmallIcon(R.drawable.ic_stat_kolibri_notification); - setPriority(NotificationCompat.PRIORITY_LOW); - try { - setColor(context.getColor(R.color.primary)); - } catch (NullPointerException e) { - // This seems to happen on Android 7 - // when this method is invoked from Python. - } - setSilent(true); + public Builder(Context context, String channelId) { + super(context, channelId); + setSmallIcon(R.drawable.ic_stat_kolibri_notification); + setPriority(NotificationCompat.PRIORITY_LOW); + try { + setColor(context.getColor(R.color.brand_primary)); + } catch (NullPointerException e) { + // This seems to happen on Android 7 + // when this method is invoked from Python. + } + setSilent(true); - // Default title - String notificationTitle = context.getApplicationContext().getString(R.string.app_name); - setContentTitle(notificationTitle); + // Default title + String notificationTitle = context.getApplicationContext().getString(R.string.app_name); + setContentTitle(notificationTitle); - // defaults for service notification channel - if (channelId.equals(NotificationRef.ID_CHANNEL_SERVICE)) { - setOngoing(true); - setCategory(NotificationCompat.CATEGORY_SERVICE); - setContentText(context.getString(R.string.notification_service_channel_content)); - setTicker(context.getString(R.string.notification_channel_ticker)); + // defaults for service notification channel + if (channelId.equals(NotificationRef.ID_CHANNEL_SERVICE)) { + setOngoing(true); + setCategory(NotificationCompat.CATEGORY_SERVICE); + setContentText(context.getString(R.string.notification_service_channel_content)); + setTicker(context.getString(R.string.notification_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_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_channel_ticker)); } + } - public Builder(Context context, int channelRef) { - this(context, NotificationRef.getChannelId(channelRef)); - } + public Builder(Context context, int channelRef) { + this(context, NotificationRef.getChannelId(channelRef)); + } - public Builder(Context context, NotificationRef ref) { - this(context, ref.getChannelRef()); - } + public Builder(Context context, NotificationRef ref) { + this(context, ref.getChannelRef()); + } - public Builder(Context context) { - this(context, NotificationRef.REF_CHANNEL_DEFAULT); - } + public Builder(Context context) { + this(context, NotificationRef.REF_CHANNEL_DEFAULT); + } } diff --git a/app/src/main/java/org/learningequality/Kolibri/notification/Manager.java b/app/src/main/java/org/learningequality/Kolibri/notification/Manager.java index 9270e450..79c9ca06 100644 --- a/app/src/main/java/org/learningequality/Kolibri/notification/Manager.java +++ b/app/src/main/java/org/learningequality/Kolibri/notification/Manager.java @@ -1,62 +1,104 @@ -package org.learningequality.notification; +package org.learningequality.Kolibri.notification; import android.Manifest; import android.app.Notification; import android.content.Context; import android.content.pm.PackageManager; - +import android.util.Log; import androidx.core.app.ActivityCompat; import androidx.core.app.NotificationManagerCompat; +import androidx.work.ForegroundInfo; +import org.learningequality.Kolibri.R; public class Manager { - private final Context context; - private final NotificationRef ref; - - public Manager(Context context, NotificationRef ref) { - this.context = context; - this.ref = ref; - } - - public void send() { - send(null, null, -1, -1); - } - - public Notification prepare(String notificationTitle, String notificationText, int notificationProgress, int notificationTotal) { - if (ref == null) { - return null; - } - Builder builder = new Builder(context, ref); - if (notificationTitle != null) { - builder.setContentTitle(notificationTitle); - } - if (notificationText != null) { - builder.setContentText(notificationText); - } - if (notificationProgress != -1 && notificationTotal != -1) { - builder.setProgress(notificationTotal, notificationProgress, false); - } - return builder.build(); - } - - public Notification send(String notificationTitle, String notificationText, int notificationProgress, int notificationTotal) { - if (ref == null) { - return null; - } - if (ActivityCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) { - // TODO: handle this case - return null; - } - Notification notification = prepare(notificationTitle, notificationText, notificationProgress, notificationTotal); - NotificationManagerCompat notificationManager = NotificationManagerCompat.from(context); - notificationManager.notify(ref.getTag(), ref.getId(), notification); - return notification; - } - - public void hide() { - if (ref == null) { - return; - } - NotificationManagerCompat notificationManager = NotificationManagerCompat.from(context); - notificationManager.cancel(ref.getTag(), ref.getId()); + private final Context context; + private final NotificationRef ref; + + public Manager(Context context, NotificationRef ref) { + this.context = context; + this.ref = ref; + } + + public Notification prepare( + String notificationTitle, + String notificationText, + int notificationProgress, + int notificationTotal) { + if (ref == null) { + return null; + } + Builder builder = new Builder(context, ref); + if (notificationTitle != null) { + builder.setContentTitle(notificationTitle); + } + if (notificationText != null) { + builder.setContentText(notificationText); + } + if (notificationProgress != -1 && notificationTotal != -1) { + builder.setProgress(notificationTotal, notificationProgress, false); + } + return builder.build(); + } + + public Notification send( + String notificationTitle, + String notificationText, + int notificationProgress, + int notificationTotal) { + if (ref == null) { + Log.w("Notification.Manager", "NotificationRef is null, cannot send notification"); + return null; + } + if (ActivityCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) + != PackageManager.PERMISSION_GRANTED) { + Log.w( + "Notification.Manager", + "POST_NOTIFICATIONS permission not granted, skipping notification"); + return null; + } + Log.d("Notification.Manager", "Sending notification: " + notificationTitle); + Notification notification = + prepare(notificationTitle, notificationText, notificationProgress, notificationTotal); + NotificationManagerCompat notificationManager = NotificationManagerCompat.from(context); + notificationManager.notify(ref.getTag(), ref.getId(), notification); + return notification; + } + + public void hide() { + if (ref == null) { + return; + } + NotificationManagerCompat notificationManager = NotificationManagerCompat.from(context); + notificationManager.cancel(ref.getTag(), ref.getId()); + } + + /** + * Create ForegroundInfo for WorkManager foreground service + * + * @param context Application context + * @param jobId Job identifier for notification + * @param foregroundServiceType Service type flags for API 29+ + * @return ForegroundInfo for the foreground worker + */ + public static ForegroundInfo createForegroundInfo( + Context context, String jobId, int foregroundServiceType) { + // Create NotificationRef using job ID + NotificationRef ref = + new NotificationRef(NotificationRef.REF_CHANNEL_DEFAULT, jobId != null ? jobId : "task"); + + // Create notification + Builder builder = new Builder(context, ref); + builder.setContentTitle(context.getString(R.string.notification_task_title)); + builder.setContentText(context.getString(R.string.notification_task_text)); + builder.setOngoing(true); + + Notification notification = builder.build(); + + // Return ForegroundInfo with or without service type + if (foregroundServiceType != 0) { + return new ForegroundInfo(ref.getId(), notification, foregroundServiceType); + } else { + return new ForegroundInfo(ref.getId(), notification); } + } } diff --git a/app/src/main/java/org/learningequality/Kolibri/notification/NotificationRef.java b/app/src/main/java/org/learningequality/Kolibri/notification/NotificationRef.java index a1cc400b..05fbaf5e 100644 --- a/app/src/main/java/org/learningequality/Kolibri/notification/NotificationRef.java +++ b/app/src/main/java/org/learningequality/Kolibri/notification/NotificationRef.java @@ -1,50 +1,49 @@ -package org.learningequality.notification; - +package org.learningequality.Kolibri.notification; 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 final String ID_CHANNEL_DEFAULT = "task_notifications"; - public static final String ID_CHANNEL_SERVICE = "background_notifications"; - private final int channelRef; - private final String tag; - private final int id; - - public NotificationRef(int channelRef, int id, String tag) { - this.channelRef = channelRef; - this.id = id; - this.tag = tag; - } - - public NotificationRef(int channelRef, String tag) { - this(channelRef, ID_DEFAULT, tag); - } - - public NotificationRef(int channelRef, int id) { - this(channelRef, id, null); - } - - public int getChannelRef() { - return channelRef; - } - - public int getId() { - return id; - } - - public String getTag() { - return tag; - } - - public static String getChannelId(int channelRef) { - switch (channelRef) { - case REF_CHANNEL_SERVICE: - return ID_CHANNEL_SERVICE; - case REF_CHANNEL_DEFAULT: - return ID_CHANNEL_DEFAULT; - default: - return null; - } + 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 final String ID_CHANNEL_DEFAULT = "task_notifications"; + public static final String ID_CHANNEL_SERVICE = "background_notifications"; + private final int channelRef; + private final String tag; + private final int id; + + public NotificationRef(int channelRef, int id, String tag) { + this.channelRef = channelRef; + this.id = id; + this.tag = tag; + } + + public NotificationRef(int channelRef, String tag) { + this(channelRef, ID_DEFAULT, tag); + } + + public NotificationRef(int channelRef, int id) { + this(channelRef, id, null); + } + + public int getChannelRef() { + return channelRef; + } + + public int getId() { + return id; + } + + public String getTag() { + return tag; + } + + public static String getChannelId(int channelRef) { + switch (channelRef) { + case REF_CHANNEL_SERVICE: + return ID_CHANNEL_SERVICE; + case REF_CHANNEL_DEFAULT: + return ID_CHANNEL_DEFAULT; + default: + return null; } + } } diff --git a/app/src/main/java/org/learningequality/Kolibri/notification/Notifier.java b/app/src/main/java/org/learningequality/Kolibri/notification/Notifier.java index 9ddc8c9b..619207e4 100644 --- a/app/src/main/java/org/learningequality/Kolibri/notification/Notifier.java +++ b/app/src/main/java/org/learningequality/Kolibri/notification/Notifier.java @@ -1,35 +1,29 @@ -package org.learningequality.notification; +package org.learningequality.Kolibri.notification; import android.app.Notification; import android.content.Context; - public interface Notifier { - Context getApplicationContext(); - - default NotificationRef getNotificationRef() { - return null; - } - - default void sendNotification() { - sendNotification(null, null, -1, -1); - } - - default Manager getNotificationManager(NotificationRef ref) { - return new Manager(getApplicationContext(), ref); - } - - default Notification sendNotification( - String notificationTitle, - String notificationText, - int notificationProgress, - int notificationTotal - ) { - return getNotificationManager(getNotificationRef()) - .send(notificationTitle, notificationText, notificationProgress, notificationTotal); - } - - default void hideNotification() { - getNotificationManager(getNotificationRef()).hide(); - } + Context getApplicationContext(); + + default NotificationRef getNotificationRef() { + return null; + } + + default Manager getNotificationManager(NotificationRef ref) { + return new Manager(getApplicationContext(), ref); + } + + default Notification sendNotification( + String notificationTitle, + String notificationText, + int notificationProgress, + int notificationTotal) { + return getNotificationManager(getNotificationRef()) + .send(notificationTitle, notificationText, notificationProgress, notificationTotal); + } + + default void hideNotification() { + getNotificationManager(getNotificationRef()).hide(); + } } diff --git a/app/src/main/java/org/learningequality/Kolibri/task/Observable.java b/app/src/main/java/org/learningequality/Kolibri/task/Observable.java index 9a531428..544febb5 100644 --- a/app/src/main/java/org/learningequality/Kolibri/task/Observable.java +++ b/app/src/main/java/org/learningequality/Kolibri/task/Observable.java @@ -1,12 +1,12 @@ -package org.learningequality.task; +package org.learningequality.Kolibri.task; import androidx.annotation.Nullable; -/** - * Small interface for an observerable which can be observed for updates with an Observer. - */ +/** Small interface for an observerable which can be observed for updates with an Observer. */ public interface Observable { - void addObserver(Observer observer); - void removeObserver(Observer observer); - void notifyObservers(@Nullable T message); + void addObserver(Observer observer); + + void removeObserver(Observer observer); + + void notifyObservers(@Nullable T message); } diff --git a/app/src/main/java/org/learningequality/Kolibri/task/Observer.java b/app/src/main/java/org/learningequality/Kolibri/task/Observer.java index d0525a13..86699e27 100644 --- a/app/src/main/java/org/learningequality/Kolibri/task/Observer.java +++ b/app/src/main/java/org/learningequality/Kolibri/task/Observer.java @@ -1,10 +1,8 @@ -package org.learningequality.task; +package org.learningequality.Kolibri.task; import androidx.annotation.Nullable; -/** - * Small interface for an observer that listens for updates from an observable. - */ +/** Small interface for an observer that listens for updates from an observable. */ public interface Observer { - void update(@Nullable T message); + void update(@Nullable T message); } diff --git a/app/src/main/java/org/learningequality/Kolibri/task/Task.java b/app/src/main/java/org/learningequality/Kolibri/task/Task.java index 1f9dc965..50fc2f72 100644 --- a/app/src/main/java/org/learningequality/Kolibri/task/Task.java +++ b/app/src/main/java/org/learningequality/Kolibri/task/Task.java @@ -1,199 +1,119 @@ -package org.learningequality; +package org.learningequality.Kolibri.task; import android.content.Context; import android.util.Log; - -import androidx.core.content.ContextCompat; +import androidx.work.Data; import androidx.work.ExistingWorkPolicy; import androidx.work.OneTimeWorkRequest; -import androidx.work.WorkInfo; -import androidx.work.WorkQuery; import androidx.work.multiprocess.RemoteWorkManager; - -import com.google.common.util.concurrent.ListenableFuture; - -import org.learningequality.Kolibri.sqlite.JobStorage; -import org.learningequality.Kolibri.task.Builder; -import org.learningequality.Kolibri.task.Reconciler; -import org.learningequality.Kolibri.task.Sentinel; -import org.learningequality.Kolibri.task.StateMap; -import org.learningequality.notification.Manager; -import org.learningequality.notification.NotificationRef; -import org.learningequality.task.Worker; - -import java.util.List; -import java.util.concurrent.ExecutionException; -import java.util.concurrent.Executor; -import java.util.concurrent.TimeoutException; -import java.util.concurrent.atomic.AtomicBoolean; - -import java9.util.concurrent.CompletableFuture; - - +import java.util.concurrent.TimeUnit; +import org.learningequality.Kolibri.util.ContextUtil; +import org.learningequality.Kolibri.workers.BackgroundWorker; +import org.learningequality.Kolibri.workers.ForegroundWorker; + +/** + * Thin Java wrapper for WorkManager task enqueueing. Called from Python + * (android_app_plugin/kolibri_plugin.py) to schedule Kolibri tasks. + * + *

This class is intentionally minimal - all database access and reconciliation logic is handled + * in Python to avoid recursive Python->Java->Python calls. + */ public class Task { - public static final String TAG = "Kolibri.Task"; - - public static String enqueueOnce(String id, int delay, boolean expedite, String jobFunc, boolean longRunning) { - RemoteWorkManager workManager = RemoteWorkManager.getInstance(ContextUtil.getApplicationContext()); - Builder.TaskRequest builder = new Builder.TaskRequest(id); - builder.setDelay(delay) - .setExpedite(expedite) - .setJobFunc(jobFunc) - .setLongRunning(longRunning); - - OneTimeWorkRequest workRequest = builder.build(); - workManager.enqueueUniqueWork(id, ExistingWorkPolicy.APPEND_OR_REPLACE, workRequest); - // return the work request ID, different from the task ID passed in - return workRequest.getId().toString(); - } - - public static void clear(String id) { - Context context = ContextUtil.getApplicationContext(); - RemoteWorkManager workManager = RemoteWorkManager.getInstance(context); - WorkQuery workQuery = Builder.TaskQuery.from(id).build(); - ListenableFuture> workInfosFuture = workManager.getWorkInfos(workQuery); - - workInfosFuture.addListener(() -> { - try { - List workInfos = workInfosFuture.get(); - if (workInfos != null) { - // Track whether the work infos are telling us this is clearable - boolean clearable = true; - // As clearable defaults to true to repeatedly && - // also make sure we actually saw any info at all - boolean anyInfo = false; - for (WorkInfo workInfo : workInfos) { - anyInfo = true; - WorkInfo.State state = workInfo.getState(); - // Clearing a task while it is still running causes some - // not great things to happen, so we should wait until - // WorkManager has determined it is not running. - clearable = clearable && state != WorkInfo.State.RUNNING; - } - if (anyInfo && clearable) { - // If the tasks are marked as completed we - workManager.cancelUniqueWork(id); - } - } - } catch (ExecutionException | InterruptedException e) { - e.printStackTrace(); - } - }, new MainThreadExecutor()); + public static final String TAG = "Kolibri.Task"; + + /** + * Enqueue a task with WorkManager from Python + * + * @param id Job ID (UUID string) + * @param delay Delay in seconds before task should run + * @param expedite Whether to expedite (high priority) this task + * @param jobFunc Kolibri job function name (for logging) + * @param longRunning Whether this is a long-running task + * @return Work request ID (different from job ID) + */ + public static String enqueueOnce( + String id, double delay, boolean expedite, String jobFunc, boolean longRunning) { + try { + Context context = ContextUtil.getApplicationContext(); + RemoteWorkManager workManager = RemoteWorkManager.getInstance(context); + + // Build work data + Data inputData = + new Data.Builder() + .putString("job_id", id) + .putString("task_id", id) + .putString("job_func", jobFunc) + .build(); + + // Select worker class based on long_running flag + Class workerClass; + if (longRunning) { + workerClass = ForegroundWorker.class; + } else { + workerClass = BackgroundWorker.class; + } + + // Build work request with delay and priority + OneTimeWorkRequest.Builder requestBuilder = + new OneTimeWorkRequest.Builder(workerClass) + .setInputData(inputData) + .addTag("kolibri:job:" + id); + + // Set initial delay if specified + if (delay > 0) { + long delayMillis = (long) (delay * 1000); + requestBuilder.setInitialDelay(delayMillis, TimeUnit.MILLISECONDS); + } + + // Set expedite flag for high priority tasks (only if no delay - can't expedite delayed jobs) + if (expedite && delay <= 0) { + requestBuilder.setExpedited( + androidx.work.OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST); + } + + OneTimeWorkRequest workRequest = requestBuilder.build(); + + // Use unique work to prevent duplicate task execution + // REPLACE policy ensures the latest schedule takes effect + workManager.enqueueUniqueWork(id, ExistingWorkPolicy.REPLACE, workRequest); + + String workRequestId = workRequest.getId().toString(); + Log.d( + TAG, + "Enqueued unique task " + + id + + " (func=" + + jobFunc + + ", delay=" + + delay + + "s, expedite=" + + expedite + + ", longRunning=" + + longRunning + + ") -> " + + workRequestId); + + return workRequestId; + + } catch (Exception e) { + Log.e(TAG, "Failed to enqueue task " + id, e); + return null; } - - public static CompletableFuture reconcile(Context context, Executor executor) { - if (executor == null) { - executor = ContextCompat.getMainExecutor(context); - } - - final AtomicBoolean didReconcile = new AtomicBoolean(false); - final JobStorage db = JobStorage.readwrite(context); - final Reconciler reconciler = Reconciler.from(context, db, executor); - - if (db == null) { - Log.e(Sentinel.TAG, "Failed to open job storage database"); - return CompletableFuture.completedFuture(false); - } - - // If we can't acquire the lock, then reconciliation is already running - if (!reconciler.begin()) { - return CompletableFuture.completedFuture(false); - } - - final Sentinel sentinel = Sentinel.from(context, db, executor); - final CompletableFuture future = new CompletableFuture<>(); - CompletableFuture chain = CompletableFuture.completedFuture(didReconcile); - - // Run through all the states and check them, then process the results - for (StateMap stateRef : StateMap.forReconciliation()) { - chain = chain.thenComposeAsync((_didReconcile) -> { - // Avoid checking if future is cancelled - synchronized (future) { - if (future.isCancelled()) { - return CompletableFuture.completedFuture(_didReconcile); - } - } - - Log.i(TAG, "Requesting sentinel check state " + stateRef); - return sentinel.check(stateRef) - .exceptionally((e) -> { - Log.e(TAG, "Failed to check state for reconciliation " + stateRef, e); - return null; - }) - .thenCompose((results) -> { - if (results != null && results.length > 0) { - Log.d(TAG, "Received results for sentinel checking " + stateRef); - _didReconcile.set(true); - return reconciler.process(stateRef, results) - .thenApply((r) -> _didReconcile); - } - return CompletableFuture.completedFuture(_didReconcile); - }); - }, executor); - } - - final CompletableFuture finalChain - = chain.orTimeout(15, java.util.concurrent.TimeUnit.SECONDS); - - finalChain.whenCompleteAsync((result, error) -> { - try { - reconciler.end(); - db.close(); - } catch (Exception e) { - Log.e(TAG, "Failed cleaning up reconciliation", e); - } finally { - synchronized (future) { - if (!future.isCancelled()) { - if (error instanceof TimeoutException) { - Log.e(TAG, "Timed out waiting for reconciliation chain", error); - future.completeExceptionally(error); - } else if (error != null) { - Log.e(TAG, "Failed during reconciliation chain", error); - future.completeExceptionally(error); - } else if (result != null) { - if (result.get()) { - Log.i(TAG, "Reconciliation completed successfully"); - } else { - Log.i(TAG, "No reconciliation performed"); - } - future.complete(result.get()); - } else { - future.complete(false); - } - } - } - } - }, executor); - - // Propagate cancellation to the chain - future.whenCompleteAsync((result, error) -> { - synchronized (future) { - if (future.isCancelled()) { - finalChain.cancel(true); - } - } - }, executor); - - return future; - } - - /** - * @param id The task request ID - * @param notificationTitle The notification title - * @param notificationText The notification text - * @param progress The task progress - * @param total The total of completed task progress - */ - public static void updateProgress( - String id, String notificationTitle, String notificationText, int progress, int total - ) { - NotificationRef ref = Worker.buildNotificationRef(id); - try { - Context context = ContextUtil.getApplicationContext(); - Manager manager = new Manager(context, ref); - manager.send(notificationTitle, notificationText, progress, total); - } catch (Exception e) { - Log.e(TAG, "Failed to update progress", e); - } + } + + /** + * Cancel a task by job ID + * + * @param id Job ID to cancel + */ + public static void clear(String id) { + try { + Context context = ContextUtil.getApplicationContext(); + RemoteWorkManager workManager = RemoteWorkManager.getInstance(context); + workManager.cancelAllWorkByTag("kolibri:job:" + id); + Log.d(TAG, "Cancelled task " + id); + } catch (Exception e) { + Log.e(TAG, "Failed to cancel task " + id, e); } + } } diff --git a/app/src/main/java/org/learningequality/Kolibri/task/TaskWorkerImpl.java b/app/src/main/java/org/learningequality/Kolibri/task/TaskWorkerImpl.java index f0ef9b0e..70fd543f 100644 --- a/app/src/main/java/org/learningequality/Kolibri/task/TaskWorkerImpl.java +++ b/app/src/main/java/org/learningequality/Kolibri/task/TaskWorkerImpl.java @@ -1,105 +1,117 @@ package org.learningequality.Kolibri.task; import android.content.Context; - +import android.util.Log; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.work.Data; - -import org.kivy.android.PythonWorker; - -import org.learningequality.task.Observer; -import org.learningequality.task.WorkerImpl; - +import com.chaquo.python.PyObject; +import com.chaquo.python.Python; import java.util.List; import java.util.UUID; -public class TaskWorkerImpl extends PythonWorker implements WorkerImpl { - private static final ThreadLocal localInstance = new ThreadLocal<>(); - private final UUID id; - private final List> observers; - - public TaskWorkerImpl(UUID id, @NonNull Context context) { - super(context, "TaskWorker", "taskworker.py"); - this.id = id; - observers = new java.util.ArrayList<>(); - localInstance.set(this); +public class TaskWorkerImpl implements WorkerImpl { + private static final String TAG = "TaskWorkerImpl"; + private static final ThreadLocal localInstance = new ThreadLocal<>(); + private final UUID id; + private final List> observers; + private final Context context; + + public TaskWorkerImpl(UUID id, @NonNull Context context) { + this.id = id; + this.context = context; + observers = new java.util.ArrayList<>(); + localInstance.set(this); + } + + /** Execute the task by calling Python taskworker module */ + @Override + public boolean execute(String id, String arg) { + try { + Python py = Python.getInstance(); + PyObject taskworkerModule = py.getModule("taskworker"); + + // Call the task worker with the job ID and WorkManager request ID + PyObject result = taskworkerModule.callAttr("execute_job", id.toString(), arg); + + // Return true if task succeeded + return result != null && result.toBoolean(); + + } catch (Exception e) { + Log.e(TAG, "Error executing task " + id, e); + return false; } + } - public void addObserver(Observer observer) { - observers.add(observer); - } - public void removeObserver(Observer observer) { - observers.remove(observer); - } + public void addObserver(Observer observer) { + observers.add(observer); + } - public void notifyObservers(@Nullable Message message) { - if (message == null) { - return; - } - for (Observer observer : observers) { - observer.update(message); - } - } + public void removeObserver(Observer observer) { + observers.remove(observer); + } - public void close() { - observers.clear(); - localInstance.remove(); + public void notifyObservers(@Nullable Message message) { + if (message == null) { + return; } - - protected Message buildMessage( - String notificationTitle, String notificationText, int progress, int total - ) { - return new Message(notificationTitle, notificationText, progress, total); + for (Observer observer : observers) { + observer.update(message); + } + } + + public void close() { + observers.clear(); + localInstance.remove(); + } + + protected Message buildMessage( + String notificationTitle, String notificationText, int progress, int total) { + return new Message(notificationTitle, notificationText, progress, total); + } + + /** This method is called by the python side, when progress is updated */ + public static void notifyLocalObservers( + String notificationTitle, String notificationText, int progress, int total) { + TaskWorkerImpl instance = localInstance.get(); + if (instance != null) { + instance.notifyObservers( + instance.buildMessage(notificationTitle, notificationText, progress, total)); + } + } + + public class Message { + public static final String KEY_ID = "id"; + public static final String KEY_NOTIFICATION_TITLE = "notificationTitle"; + public static final String KEY_NOTIFICATION_TEXT = "notificationText"; + public static final String KEY_PROGRESS = "progress"; + public static final String KEY_TOTAL_PROGRESS = "totalProgress"; + + public final String notificationTitle; + public final String notificationText; + public final int progress; + public final int totalProgress; + + public Message( + String notificationTitle, String notificationText, int progress, int totalProgress) { + this.notificationTitle = notificationTitle; + this.notificationText = notificationText; + this.progress = progress; + this.totalProgress = totalProgress; } - /** - * This method is called by the python side, when progress is updated - */ - public static void notifyLocalObservers( - String notificationTitle, String notificationText, int progress, int total - ) { - TaskWorkerImpl instance = localInstance.get(); - if (instance != null) { - instance.notifyObservers( - instance.buildMessage(notificationTitle, notificationText, progress, total) - ); - } + public UUID getId() { + return id; } - public class Message { - public static final String KEY_ID = "id"; - public static final String KEY_NOTIFICATION_TITLE = "notificationTitle"; - public static final String KEY_NOTIFICATION_TEXT = "notificationText"; - public static final String KEY_PROGRESS = "progress"; - public static final String KEY_TOTAL_PROGRESS = "totalProgress"; - - public final String notificationTitle; - public final String notificationText; - public final int progress; - public final int totalProgress; - - public Message( - String notificationTitle, String notificationText, int progress, int totalProgress - ) { - this.notificationTitle = notificationTitle; - this.notificationText = notificationText; - this.progress = progress; - this.totalProgress = totalProgress; - } - - public UUID getId() { - return id; - } - - public Data toData() { - return new Data.Builder() - .putString(KEY_ID, id.toString()) - .putString(KEY_NOTIFICATION_TITLE, notificationTitle) - .putString(KEY_NOTIFICATION_TEXT, notificationText) - .putInt(KEY_PROGRESS, progress) - .putInt(KEY_TOTAL_PROGRESS, totalProgress) - .build(); - } + public Data toData() { + return new Data.Builder() + .putString(KEY_ID, id.toString()) + .putString(KEY_NOTIFICATION_TITLE, notificationTitle) + .putString(KEY_NOTIFICATION_TEXT, notificationText) + .putInt(KEY_PROGRESS, progress) + .putInt(KEY_TOTAL_PROGRESS, totalProgress) + .build(); } + } } diff --git a/app/src/main/java/org/learningequality/Kolibri/task/WorkerImpl.java b/app/src/main/java/org/learningequality/Kolibri/task/WorkerImpl.java new file mode 100644 index 00000000..c4f27894 --- /dev/null +++ b/app/src/main/java/org/learningequality/Kolibri/task/WorkerImpl.java @@ -0,0 +1,9 @@ +package org.learningequality.Kolibri.task; + +/** + * Interface for defining a worker implementation that can be observed for updates, and handles + * execution of a task, and cleanup of resources implementing AutoCloseable. + */ +public interface WorkerImpl extends Observable, AutoCloseable { + boolean execute(String id, String arg); +} diff --git a/app/src/main/java/org/learningequality/Kolibri/util/AuthUtils.java b/app/src/main/java/org/learningequality/Kolibri/util/AuthUtils.java new file mode 100644 index 00000000..88932707 --- /dev/null +++ b/app/src/main/java/org/learningequality/Kolibri/util/AuthUtils.java @@ -0,0 +1,106 @@ +package org.learningequality.Kolibri.util; + +import android.content.Context; +import android.content.SharedPreferences; +import java.security.SecureRandom; +import org.learningequality.Kolibri.R; + +/** + * Utility for OS user authentication. Provides persistent auth token storage and validation. Called + * from Python via Chaquopy and from Java. + */ +public class AuthUtils { + private static final String PREFS_NAME = "kolibri_auth"; + private static final String KEY_AUTH_TOKEN = "os_user_auth_token"; + // Legacy P4A storage: file in .value_cache directory + private static final String LEGACY_CACHE_DIR = ".value_cache"; + private static final String LEGACY_CACHE_KEY = "OS_USER_AUTH_TOKEN"; + private static final int TOKEN_BYTES = 16; // 16 bytes = 32 hex chars + private static final char[] HEX_CHARS = "0123456789abcdef".toCharArray(); + + /** + * Get or create a persistent auth token for the OS user. Token persists across app restarts via + * SharedPreferences. Migrates from legacy P4A file-based storage if needed. + * + * @return The auth token (32 character hex string) + */ + public static synchronized String getOrCreateAuthToken() { + Context context = ContextUtil.getApplicationContext(); + SharedPreferences prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE); + + String token = prefs.getString(KEY_AUTH_TOKEN, null); + if (token == null) { + // Check legacy P4A file-based storage for migration + token = migrateLegacyToken(context, prefs); + } + if (token == null) { + // Generate new token + byte[] tokenBytes = new byte[TOKEN_BYTES]; + new SecureRandom().nextBytes(tokenBytes); + token = bytesToHex(tokenBytes); + prefs.edit().putString(KEY_AUTH_TOKEN, token).apply(); + } + return token; + } + + /** + * Migrate token from legacy P4A file-based storage (.value_cache/OS_USER_AUTH_TOKEN). The old P4A + * version used AndroidValueCache which stored values as files. Returns the migrated token, or + * null if no legacy token exists. + */ + private static String migrateLegacyToken(Context context, SharedPreferences newPrefs) { + java.io.File externalFilesDir = context.getExternalFilesDir(null); + if (externalFilesDir == null) { + return null; + } + java.io.File legacyFile = + new java.io.File(new java.io.File(externalFilesDir, LEGACY_CACHE_DIR), LEGACY_CACHE_KEY); + if (!legacyFile.exists()) { + return null; + } + try (java.io.BufferedReader reader = + new java.io.BufferedReader(new java.io.FileReader(legacyFile))) { + String legacyToken = reader.readLine(); + if (legacyToken != null && !legacyToken.isEmpty()) { + legacyToken = legacyToken.trim(); + // Migrate to SharedPreferences + newPrefs.edit().putString(KEY_AUTH_TOKEN, legacyToken).commit(); + // Delete legacy file + legacyFile.delete(); + return legacyToken; + } + } catch (java.io.IOException e) { + // Ignore - will generate new token + } + return null; + } + + private static String bytesToHex(byte[] bytes) { + char[] hexChars = new char[bytes.length * 2]; + for (int i = 0; i < bytes.length; i++) { + int v = bytes[i] & 0xFF; + hexChars[i * 2] = HEX_CHARS[v >>> 4]; + hexChars[i * 2 + 1] = HEX_CHARS[v & 0x0F]; + } + return new String(hexChars); + } + + /** + * Validate an auth token. + * + * @param token The token to validate + * @return true if token matches the stored token + */ + public static boolean validateAuthToken(String token) { + return token != null && token.equals(getOrCreateAuthToken()); + } + + /** + * Get the localized username for the OS user. + * + * @return Localized "Learner" string + */ + public static String getLocalizedUsername() { + return ContextUtil.getApplicationContext().getString(R.string.os_user_name); + } +} diff --git a/app/src/main/java/org/learningequality/Kolibri/util/ContextUtil.java b/app/src/main/java/org/learningequality/Kolibri/util/ContextUtil.java index d47f5ce8..72c4c405 100644 --- a/app/src/main/java/org/learningequality/Kolibri/util/ContextUtil.java +++ b/app/src/main/java/org/learningequality/Kolibri/util/ContextUtil.java @@ -1,22 +1,45 @@ -package org.learningequality; +package org.learningequality.Kolibri.util; import android.content.Context; -import org.kivy.android.PythonActivity; -import org.kivy.android.PythonProvider; - +/** + * Utility to provide application context to both Java and Python code. + * + *

Must be initialized early in App.onCreate() before any other components try to access the + * context. + */ public class ContextUtil { - public static Context getApplicationContext() { - if (PythonProvider.isActive()) { - return PythonProvider.get().getContext(); - } - if (isActivityContext()) { - return PythonActivity.mActivity.getApplicationContext(); - } - return null; + private static volatile Context applicationContext; + + /** Initialize with application context. Must be called from App.onCreate(). */ + public static void init(Context context) { + applicationContext = context.getApplicationContext(); + } + + /** + * Get the application context. + * + * @return Application context + * @throws IllegalStateException if init() was not called + */ + public static Context getApplicationContext() { + if (applicationContext == null) { + throw new IllegalStateException( + "ContextUtil not initialized. Call ContextUtil.init() from App.onCreate() first."); } + return applicationContext; + } - public static boolean isActivityContext() { - return PythonActivity.mActivity != null; + /** + * Get the app's external files directory path. Called from Python via Chaquopy. + * + * @return Path to external files directory + */ + public static String getExternalFilesDir() { + java.io.File dir = getApplicationContext().getExternalFilesDir(null); + if (dir == null) { + dir = getApplicationContext().getFilesDir(); } + return dir.getAbsolutePath(); + } } diff --git a/app/src/main/java/org/learningequality/Kolibri/util/NetworkUtils.java b/app/src/main/java/org/learningequality/Kolibri/util/NetworkUtils.java index 7adfb1c3..d7430fac 100644 --- a/app/src/main/java/org/learningequality/Kolibri/util/NetworkUtils.java +++ b/app/src/main/java/org/learningequality/Kolibri/util/NetworkUtils.java @@ -1,5 +1,7 @@ -package org.learningequality; +package org.learningequality.Kolibri.util; +import android.content.Context; +import android.net.ConnectivityManager; import java.net.Inet4Address; import java.net.InetAddress; import java.net.NetworkInterface; @@ -8,42 +10,53 @@ import java.util.Collections; import java.util.List; - public class NetworkUtils { - public static List getActiveIPv4Addresses() { - List ipAddresses = new ArrayList<>(); + /** + * Check if the active network connection is metered. Called from Python via Chaquopy. + * + * @return true if metered, false otherwise + */ + public static boolean isActiveNetworkMetered() { + Context context = ContextUtil.getApplicationContext(); + ConnectivityManager cm = + (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE); + return cm != null && cm.isActiveNetworkMetered(); + } + + public static List getActiveIPv4Addresses() { + List ipAddresses = new ArrayList<>(); + + List networkInterfaces; + try { + networkInterfaces = Collections.list(NetworkInterface.getNetworkInterfaces()); + } catch (SocketException e) { + e.printStackTrace(); + return ipAddresses; // Return empty list if there's a problem fetching network interfaces + } - List networkInterfaces; - try { - networkInterfaces = Collections.list(NetworkInterface.getNetworkInterfaces()); - } catch (SocketException e) { - e.printStackTrace(); - return ipAddresses; // Return empty list if there's a problem fetching network interfaces + for (NetworkInterface networkInterface : networkInterfaces) { + try { + // Check if the network interface is up (active) + if (!networkInterface.isUp() || !networkInterface.supportsMulticast()) { + continue; // Skip inactive interfaces, and interfaces that don't support multicast } - for (NetworkInterface networkInterface : networkInterfaces) { - try { - // Check if the network interface is up (active) - if (!networkInterface.isUp() || !networkInterface.supportsMulticast()) { - continue; // Skip inactive interfaces, and interfaces that don't support multicast - } - - // Get all IP addresses associated with the interface - List inetAddresses = Collections.list(networkInterface.getInetAddresses()); - - for (InetAddress inetAddress : inetAddresses) { - // Ensure this is an IPv4 address - if (inetAddress instanceof Inet4Address) { - ipAddresses.add(inetAddress.getHostAddress()); - } - } - } catch (SocketException e) { - e.printStackTrace(); // Handle or log the error for this particular network interface - // Continue with the next interface - } - } + // Get all IP addresses associated with the interface + List inetAddresses = Collections.list(networkInterface.getInetAddresses()); - return ipAddresses; + for (InetAddress inetAddress : inetAddresses) { + // Ensure this is an IPv4 address + if (inetAddress instanceof Inet4Address) { + ipAddresses.add(inetAddress.getHostAddress()); + } + } + } catch (SocketException e) { + e.printStackTrace(); // Handle or log the error for this particular network interface + // Continue with the next interface + } } + + return ipAddresses; + } } diff --git a/app/src/main/java/org/learningequality/Kolibri/util/ShareUtils.java b/app/src/main/java/org/learningequality/Kolibri/util/ShareUtils.java new file mode 100644 index 00000000..85551f17 --- /dev/null +++ b/app/src/main/java/org/learningequality/Kolibri/util/ShareUtils.java @@ -0,0 +1,59 @@ +package org.learningequality.Kolibri.util; + +import android.content.ActivityNotFoundException; +import android.content.Context; +import android.content.Intent; +import android.net.Uri; +import android.util.Log; +import androidx.core.content.FileProvider; +import java.io.File; + +/** + * Utility for sharing content via Android's share intent system. Called from Python via Chaquopy. + */ +public class ShareUtils { + private static final String TAG = "ShareUtils"; + + /** + * Share content via Android intent system. + * + * @param path File path to share (optional) + * @param message Text message to share (optional) + * @param app Target app package name (optional, null for chooser) + * @param mimetype MIME type (optional, defaults based on content) + */ + public static void shareByIntent(String path, String message, String app, String mimetype) { + Context context = ContextUtil.getApplicationContext(); + + Intent sendIntent = new Intent(); + sendIntent.setAction(Intent.ACTION_SEND); + + if (path != null && !path.isEmpty()) { + // Share file + String authority = context.getPackageName() + ".fileprovider"; + Uri uri = FileProvider.getUriForFile(context, authority, new File(path)); + + sendIntent.putExtra(Intent.EXTRA_STREAM, uri); + sendIntent.setType(mimetype != null ? mimetype : "*/*"); + sendIntent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); + } + + if (message != null && !message.isEmpty()) { + if (path == null || path.isEmpty()) { + sendIntent.setType(mimetype != null ? mimetype : "text/plain"); + } + sendIntent.putExtra(Intent.EXTRA_TEXT, message); + } + + if (app != null && !app.isEmpty()) { + sendIntent.setPackage(app); + } + + sendIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + try { + context.startActivity(sendIntent); + } catch (ActivityNotFoundException e) { + Log.e(TAG, "No activity found to handle share intent", e); + } + } +} diff --git a/app/src/main/java/org/learningequality/Kolibri/workers/BackgroundWorker.java b/app/src/main/java/org/learningequality/Kolibri/workers/BackgroundWorker.java index aa9801b6..2d4e9617 100644 --- a/app/src/main/java/org/learningequality/Kolibri/workers/BackgroundWorker.java +++ b/app/src/main/java/org/learningequality/Kolibri/workers/BackgroundWorker.java @@ -1,33 +1,22 @@ -package org.learningequality.Kolibri; - +package org.learningequality.Kolibri.workers; import android.content.Context; -import android.util.Log; - import androidx.annotation.NonNull; import androidx.work.WorkerParameters; -import org.learningequality.notification.Manager; -import org.learningequality.notification.NotificationRef; -import org.learningequality.task.Worker; - -import org.learningequality.Kolibri.task.TaskWorkerImpl; - /** - * Background worker that runs a Python task in a background thread. This will likely be run by the - * SystemJobService. + * Background worker for short/low-priority Kolibri tasks + * + *

No persistent notification, can be killed by system if resources needed. */ -final public class BackgroundWorker extends Worker { - private static final String TAG = "Kolibri.BackgroundWorker"; +public class BackgroundWorker extends BaseTaskWorker { - public BackgroundWorker( - @NonNull Context context, @NonNull WorkerParameters workerParams - ) { - super(context, workerParams); - } + public BackgroundWorker(@NonNull Context context, @NonNull WorkerParameters params) { + super(context, params); + } - protected TaskWorkerImpl getWorkerImpl() { - Log.d(TAG, "Starting background task: " + getId()); - return new TaskWorkerImpl(getId(), getApplicationContext()); - } + @Override + protected String getWorkerType() { + return "background"; + } } diff --git a/app/src/main/java/org/learningequality/Kolibri/workers/BaseTaskWorker.java b/app/src/main/java/org/learningequality/Kolibri/workers/BaseTaskWorker.java new file mode 100644 index 00000000..5eb45a1e --- /dev/null +++ b/app/src/main/java/org/learningequality/Kolibri/workers/BaseTaskWorker.java @@ -0,0 +1,126 @@ +package org.learningequality.Kolibri.workers; + +import android.content.Context; +import android.util.Log; +import androidx.annotation.NonNull; +import androidx.work.Data; +import androidx.work.Worker; +import androidx.work.WorkerParameters; +import java.util.zip.CRC32; +import org.learningequality.Kolibri.KolibriEnvironmentSetup; +import org.learningequality.Kolibri.notification.NotificationRef; +import org.learningequality.Kolibri.notification.Notifier; +import org.learningequality.Kolibri.task.Observer; +import org.learningequality.Kolibri.task.TaskWorkerImpl; + +/** + * Base class for Kolibri task workers + * + *

Provides common functionality for executing Python tasks via Chaquopy. Sets up TaskWorkerImpl + * with observer pattern for progress notifications. Subclasses implement getWorkerType() to + * differentiate behavior. + */ +public abstract class BaseTaskWorker extends Worker implements Notifier { + private static final String TAG = "BaseTaskWorker"; + private Data lastProgressData; + + public BaseTaskWorker(@NonNull Context context, @NonNull WorkerParameters params) { + super(context, params); + } + + /** + * Get the worker type for Python + * + * @return "foreground" or "background" + */ + protected abstract String getWorkerType(); + + @NonNull + @Override + public Result doWork() { + String jobId = null; + TaskWorkerImpl workerImpl = null; + + try { + Log.d(TAG, "Starting " + getWorkerType() + " task execution"); + + // Initialize Python and Kolibri environment in task_worker process. + // Skip migrations (skipUpdate=true) — only the main process runs migrations + // to avoid concurrent migration races on the shared database. + KolibriEnvironmentSetup.initializeEnv(getApplicationContext(), true); + + // Get job ID from input data + jobId = getInputData().getString("job_id"); + if (jobId == null || jobId.isEmpty()) { + Log.e(TAG, "No job_id provided"); + return Result.failure(); + } + + Log.i(TAG, "Executing job: " + jobId + " (type: " + getWorkerType() + ")"); + + // Create TaskWorkerImpl - this sets up the ThreadLocal so Python can notify us + workerImpl = new TaskWorkerImpl(getId(), getApplicationContext()); + workerImpl.addObserver( + new Observer() { + @Override + public void update(TaskWorkerImpl.Message message) { + onProgressUpdate(message); + } + }); + + // Execute the task via TaskWorkerImpl (delegates to Python) + boolean success = workerImpl.execute(jobId, getId().toString()); + Log.i(TAG, "Task " + jobId + " completed: " + (success ? "SUCCESS" : "FAILURE")); + + return success ? Result.success() : Result.failure(); + + } catch (Exception e) { + Log.e(TAG, "Error executing " + getWorkerType() + " task", e); + return Result.failure(); + } finally { + // Clean up TaskWorkerImpl + if (workerImpl != null) { + workerImpl.close(); + } + // Hide notification when task completes + hideNotification(); + } + } + + /** Handle progress update from Python via TaskWorkerImpl observer */ + protected void onProgressUpdate(TaskWorkerImpl.Message message) { + Log.d( + TAG, + "onProgressUpdate called: title=" + + message.notificationTitle + + ", text=" + + message.notificationText); + Data updateData = message.toData(); + // Only update progress if it has changed + if (updateData.equals(lastProgressData)) { + Log.d(TAG, "Progress unchanged, skipping notification update"); + return; + } + lastProgressData = updateData; + // Log and track progress + setProgressAsync(updateData); + try { + sendNotification( + message.notificationTitle, + message.notificationText, + message.progress, + message.totalProgress); + } catch (Exception e) { + Log.e(TAG, "Failed to update task progress for: " + getId(), e); + } + } + + @Override + public NotificationRef getNotificationRef() { + // Use CRC32 to generate a unique notification ID from the work request ID + CRC32 crc = new CRC32(); + crc.update(getId().toString().getBytes()); + int notificationId = (int) crc.getValue(); + return new NotificationRef(NotificationRef.REF_CHANNEL_DEFAULT, notificationId); + } +} diff --git a/app/src/main/java/org/learningequality/Kolibri/workers/ForegroundWorker.java b/app/src/main/java/org/learningequality/Kolibri/workers/ForegroundWorker.java index 003a80b4..5e3577aa 100644 --- a/app/src/main/java/org/learningequality/Kolibri/workers/ForegroundWorker.java +++ b/app/src/main/java/org/learningequality/Kolibri/workers/ForegroundWorker.java @@ -1,65 +1,56 @@ -package org.learningequality.Kolibri; +package org.learningequality.Kolibri.workers; -import android.app.Notification; +import android.content.Context; import android.content.pm.ServiceInfo; -import android.util.Log; - +import android.os.Build; import androidx.annotation.NonNull; -import androidx.concurrent.futures.CallbackToFutureAdapter; import androidx.work.ForegroundInfo; - -import com.google.common.util.concurrent.ListenableFuture; - -import org.learningequality.Kolibri.task.TaskWorkerImpl; -import org.learningequality.notification.Builder; -import org.learningequality.notification.NotificationRef; -import org.learningequality.task.Worker; - -final public class ForegroundWorker extends Worker { - private static final String TAG = "Kolibri.ForegroundWorker"; - - public ForegroundWorker( - @NonNull android.content.Context context, - @NonNull androidx.work.WorkerParameters workerParams - ) { - super(context, workerParams); - } - - protected TaskWorkerImpl getWorkerImpl() { - Log.d(TAG, "Starting foreground task: " + getId()); - return new TaskWorkerImpl(getId(), getApplicationContext()); - } - - @Override - @NonNull - public Result doWork() { - Log.d(TAG, "Setting task as foreground: " + getId()); - setForegroundAsync(getForegroundInfo()); - return super.doWork(); +import androidx.work.WorkerParameters; +import org.learningequality.Kolibri.notification.Manager; + +/** + * Foreground worker for long-running/high-priority Kolibri tasks + * + *

Shows persistent notification and uses foreground service to prevent system from killing the + * worker during execution. + */ +public class ForegroundWorker extends BaseTaskWorker { + private static final String TAG = "ForegroundWorker"; + + public ForegroundWorker(@NonNull Context context, @NonNull WorkerParameters params) { + super(context, params); + } + + @Override + protected String getWorkerType() { + return "foreground"; + } + + @NonNull + @Override + public Result doWork() { + try { + setForegroundAsync(getForegroundInfo()); + } catch (Exception e) { + android.util.Log.w(TAG, "Failed to set foreground", e); } - - @NonNull - public ForegroundInfo getForegroundInfo() { - NotificationRef ref = getNotificationRef(); - Notification lastNotification = this.getLastNotification(); - if (lastNotification == null) { - // build default notification - lastNotification = new Builder(getApplicationContext(), ref).build(); - } - // If API level is at least 29 - if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.Q) { - return new ForegroundInfo( - ref.getId(), - lastNotification, - ServiceInfo.FOREGROUND_SERVICE_TYPE_MANIFEST - ); - } - return new ForegroundInfo(ref.getId(), lastNotification); - } - - @Override - @NonNull - public ListenableFuture getForegroundInfoAsync() { - return CallbackToFutureAdapter.getFuture(completer -> completer.set(getForegroundInfo())); + return super.doWork(); + } + + @NonNull + @Override + public ForegroundInfo getForegroundInfo() { + // Get job ID for notification + String jobId = getInputData().getString("job_id"); + + // Create notification via Manager + return Manager.createForegroundInfo(getApplicationContext(), jobId, getForegroundServiceType()); + } + + private int getForegroundServiceType() { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + return ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC; } + return 0; + } } diff --git a/app/src/main/python/android_app_plugin/__init__.py b/app/src/main/python/android_app_plugin/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/app/src/main/python/android_app_plugin/kolibri_plugin.py b/app/src/main/python/android_app_plugin/kolibri_plugin.py new file mode 100644 index 00000000..0f96167d --- /dev/null +++ b/app/src/main/python/android_app_plugin/kolibri_plugin.py @@ -0,0 +1,115 @@ +import logging + +from auth import os_user +from django.utils import timezone +from java import jclass +from java.util import Locale + +from kolibri.core.content.hooks import ShareFileHook +from kolibri.core.device.hooks import CheckIsMeteredHook +from kolibri.core.device.hooks import GetOSUserHook +from kolibri.core.tasks.hooks import JobHook +from kolibri.core.tasks.job import Priority +from kolibri.plugins import KolibriPluginBase +from kolibri.plugins.hooks import register_hook + +NetworkUtils = jclass("org.learningequality.Kolibri.util.NetworkUtils") +ShareUtils = jclass("org.learningequality.Kolibri.util.ShareUtils") +Task = jclass("org.learningequality.Kolibri.task.Task") +TaskWorker = jclass("org.learningequality.Kolibri.task.TaskWorkerImpl") +PROGRESS_LIMIT = 10000 + + +logger = logging.getLogger(__name__) + + +class AndroidApp(KolibriPluginBase): + pass + + +@register_hook +class AndroidGetOSUserHook(GetOSUserHook): + def get_os_user(self, auth_token=None): + return os_user(auth_token) + + +@register_hook +class AndroidCheckIsMeteredHook(CheckIsMeteredHook): + def check_is_metered(self): + try: + return bool(NetworkUtils.isActiveNetworkMetered()) + except Exception: + return False + + +@register_hook +class AndroidShareFileHook(ShareFileHook): + def share_file(self, filename, message): + ShareUtils.shareByIntent(filename or "", message or "") + + +@register_hook +class AndroidJobHook(JobHook): + def schedule( + self, + job, + orm_job, + ): + if orm_job.id: + delay = 0 + if orm_job.scheduled_time: + now = timezone.now() + delay = max(0, (orm_job.scheduled_time - now).total_seconds()) + + high_priority = orm_job.priority <= Priority.HIGH + + # Android has no mechanism for scheduling a limited run of repeating tasks, + # so we just schedule it as a one-off task, and then re-schedule it when the task + # is completed. + # We could use WorkManager's PeriodicWorkRequest, but this gives us more control + # over execution, and also allows us to use the same mechanism for all tasks. + # Similarly, retry_intervals are handled by the schedule mechanism, so we don't + # leverage Android's retry mechanism either. + logger.info( + "Scheduling task {} for job {} with delay {} and high priority {}".format( + job.func, orm_job.id, delay, high_priority + ) + ) + request_id = Task.enqueueOnce( + orm_job.id, + delay, + high_priority, + job.func, + job.long_running, + ) + job.update_worker_info(extra=request_id) + + def update(self, job, orm_job, state=None, **kwargs): + currentLocale = Locale.getDefault().toLanguageTag() + + status = job.status(currentLocale) + + if status: + if job.total_progress: + progress = job.progress + total_progress = job.total_progress + else: + progress = -1 + total_progress = -1 + + # avoid passing integers that are too large + # PROGRESS_LIMIT gives sufficient precision for a % progress calculation + if total_progress > PROGRESS_LIMIT: + progress = PROGRESS_LIMIT * progress // total_progress + total_progress = PROGRESS_LIMIT + + TaskWorker.notifyLocalObservers( + status.title, + status.text, + progress, + total_progress, + ) + + def clear(self, job, orm_job): + logger.info("Clearing task {} for job {}".format(job.func, orm_job.id)) + Task.clear(orm_job.id) diff --git a/app/src/main/python/auth.py b/app/src/main/python/auth.py new file mode 100644 index 00000000..5589cbda --- /dev/null +++ b/app/src/main/python/auth.py @@ -0,0 +1,23 @@ +""" +Android utilities for Kolibri +Provides OS user authentication via Java utilities +""" + +from java import jclass + +AuthUtils = jclass("org.learningequality.Kolibri.util.AuthUtils") + + +def get_os_user_auth_token(): + """Get or generate persistent auth token for OS user""" + return AuthUtils.getOrCreateAuthToken() + + +def os_user(auth_token): + """ + Validate auth token and return OS user info. + Returns (username, is_valid) tuple. + """ + if AuthUtils.validateAuthToken(auth_token): + return (AuthUtils.getLocalizedUsername(), True) + return (None, False) diff --git a/app/src/main/python/kolibri_app_settings.py b/app/src/main/python/kolibri_app_settings.py new file mode 100644 index 00000000..149f51d2 --- /dev/null +++ b/app/src/main/python/kolibri_app_settings.py @@ -0,0 +1,8 @@ +from __future__ import absolute_import +from __future__ import print_function +from __future__ import unicode_literals + +from kolibri.deployment.default.settings.base import * # noqa E402 + +SESSION_EXPIRE_AT_BROWSER_CLOSE = False +SESSION_COOKIE_AGE = 52560000 diff --git a/app/src/main/python/main.py b/app/src/main/python/main.py index e134e057..21a4b436 100644 --- a/app/src/main/python/main.py +++ b/app/src/main/python/main.py @@ -1,66 +1,149 @@ +""" +Kolibri Server Entry Point for Chaquopy + +This module provides the AndroidKolibriProcessBus which can be started +from Java via KolibriServerService. +""" + import logging -import initialization # noqa: F401 keep this first, to ensure we're set up for other imports -from android_utils import get_os_user_auth_token -from jnius import autoclass +from auth import get_os_user_auth_token +from magicbus.plugins import SimplePlugin +from org.learningequality.Kolibri import KolibriServerViewModel + +import kolibri.utils.logger as kolibri_logger from kolibri.core.device.utils import app_initialize_url -from kolibri.main import enable_plugin -from kolibri.utils.cli import initialize from kolibri.utils.server import BaseKolibriProcessBus from kolibri.utils.server import KolibriServerPlugin from kolibri.utils.server import ZeroConfPlugin from kolibri.utils.server import ZipContentServerPlugin -from magicbus.plugins import SimplePlugin -from runnable import Runnable - -PythonActivity = autoclass("org.kivy.android.PythonActivity") +logger = logging.getLogger(__name__) -FullScreen = autoclass("org.learningequality.FullScreen") -configureWebview = Runnable(FullScreen.configureWebview) -configureWebview(PythonActivity.mActivity) +# Module-level reference to the running server bus (for shutdown) +_server_bus = None -loadUrl = Runnable(PythonActivity.mWebView.loadUrl) -auth_token_value = get_os_user_auth_token() +def _get_auth_token(): + """Get auth token lazily to avoid import-time issues""" + return get_os_user_auth_token() class AppPlugin(SimplePlugin): + """ + Plugin that handles server state monitoring + + Signals Java via ViewModel when HTTP server is ready. + """ + def __init__(self, bus): self.bus = bus self.bus.subscribe("SERVING", self.SERVING) def SERVING(self, port): - start_url = "http://127.0.0.1:{port}".format(port=port) + app_initialize_url( - auth_token=auth_token_value - ) - loadUrl(start_url) - - -logging.info("Initializing Kolibri and running any upgrade routines") - - -enable_plugin("android_app_plugin") - -# we need to initialize Kolibri to allow us to access the app key -initialize() - -kolibri_bus = BaseKolibriProcessBus() -# Setup zeroconf plugin -zeroconf_plugin = ZeroConfPlugin(kolibri_bus, kolibri_bus.port) -zeroconf_plugin.subscribe() -kolibri_server = KolibriServerPlugin( - kolibri_bus, - kolibri_bus.port, -) - -alt_port_server = ZipContentServerPlugin( - kolibri_bus, - kolibri_bus.zip_port, -) -# Subscribe these servers -kolibri_server.subscribe() -alt_port_server.subscribe() -app_plugin = AppPlugin(kolibri_bus) -app_plugin.subscribe() -kolibri_bus.run() + """Called when server reaches SERVING state""" + logger.info("Kolibri server ready on port %s", port) + KolibriServerViewModel.getInstance().setServerReady(True) + + +def get_initialize_url(next_url=None): + """Build the full initialization URL with auth token. + + Called from Java when the WebView needs to load Kolibri. + Uses the running server's port from the bus. + """ + if _server_bus is None: + raise RuntimeError("Server is not running") + auth_token = _get_auth_token() + path = app_initialize_url(auth_token=auth_token, next_url=next_url) + return "http://127.0.0.1:{port}".format(port=_server_bus.port) + path + + +class AndroidKolibriProcessBus(BaseKolibriProcessBus): + """ + Kolibri process bus for Android with Chaquopy + + This bus manages the Kolibri HTTP server lifecycle. + Server handles both local WebView and remote peer connections. + """ + + def __init__(self): + super().__init__() + self._setup_plugins() + + def _setup_plugins(self): + """Setup all required server plugins""" + # Setup zeroconf plugin + zeroconf_plugin = ZeroConfPlugin(self, self.port) + zeroconf_plugin.subscribe() + + # Setup main Kolibri server + kolibri_server = KolibriServerPlugin(self, self.port) + kolibri_server.subscribe() + + # Setup zip content server (for alternate port) + alt_port_server = ZipContentServerPlugin(self, self.zip_port) + alt_port_server.subscribe() + + # Setup app plugin for state monitoring + app_plugin = AppPlugin(self) + app_plugin.subscribe() + + def stop(self): + """Stop the server""" + self.transition("EXITED") + + +def start_server(): + """ + Start the Kolibri HTTP server + Called from Java KolibriServerService + + Runs HTTP server for both local WebView (via Service Worker) + and remote peer connections. Blocks until server stops. + + Note: Kolibri initialization is done in KolibriEnvironmentSetup.java + via kolibri.main.initialize() - do NOT call initialize() here again + or it will fail with "Attempted to update plugins when registry is initialized" + """ + global _server_bus + + logger.info("Starting Kolibri server") + + # Reset queue logging flag so the server can reinitialize logging on restart. + # Kolibri's _replace_handlers_with_queue sets this flag to True and never resets + # it, so a second start_server() call in the same process would fail. + kolibri_logger._queue_logging_initialized_for_process = False + + # Create and run server bus + logger.info("Creating Kolibri server bus") + bus = AndroidKolibriProcessBus() + _server_bus = bus + + try: + logger.info("Starting Kolibri server") + # Note: This blocks until server stops + bus.run() + finally: + _server_bus = None + # Reset server state in ViewModel + KolibriServerViewModel.getInstance().resetServerState() + + return bus + + +def stop_server(): + """ + Stop the Kolibri HTTP server + Called from Java KolibriServerService.onDestroy() + """ + global _server_bus + + if _server_bus is not None: + logger.info("Stopping Kolibri server") + try: + _server_bus.stop() + except Exception as e: + logger.error(f"Error stopping server: {e}", exc_info=True) + else: + logger.warning("stop_server called but no server is running") diff --git a/app/src/main/python/monkey_patch_zeroconf.py b/app/src/main/python/monkey_patch_zeroconf.py index e94c7239..5f48a2a2 100644 --- a/app/src/main/python/monkey_patch_zeroconf.py +++ b/app/src/main/python/monkey_patch_zeroconf.py @@ -3,13 +3,16 @@ # kolibri import must come first: it puts its dist folder (zeroconf) on sys.path import kolibri # noqa: F401 # isort: skip import zeroconf -from jnius import autoclass +from java import jclass -NetworkUtils = autoclass("org.learningequality.NetworkUtils") +NetworkUtils = jclass("org.learningequality.Kolibri.util.NetworkUtils") def get_all_addresses(): - return list(NetworkUtils.getActiveIPv4Addresses()) + # Get Java List and convert to Python list + # Chaquopy Java collections need explicit conversion via toArray() + java_list = NetworkUtils.getActiveIPv4Addresses() + return [str(addr) for addr in java_list.toArray()] # kolibri.utils.server binds get_all_addresses at import time; a stale (ifaddr) diff --git a/app/src/main/python/task_reconciler.py b/app/src/main/python/task_reconciler.py new file mode 100644 index 00000000..f817e1ab --- /dev/null +++ b/app/src/main/python/task_reconciler.py @@ -0,0 +1,217 @@ +""" +Task reconciliation system for Android +Syncs WorkManager state with Kolibri job database +""" + +import logging +import os + +from java import jclass + +from kolibri.core.tasks.job import State +from kolibri.core.tasks.main import job_storage + +logger = logging.getLogger(__name__) + +# Java classes for WorkManager interaction +Task = jclass("org.learningequality.Kolibri.task.Task") + + +def _get_workmanager_job_ids(): + """ + Get all active job IDs from WorkManager (ENQUEUED and RUNNING states) + + Returns: + set: Set of job ID strings + """ + WorkManager = jclass("androidx.work.WorkManager") + WorkInfo = jclass("androidx.work.WorkInfo") + WorkQuery = jclass("androidx.work.WorkQuery") + ContextUtil = jclass("org.learningequality.Kolibri.util.ContextUtil") + Arrays = jclass("java.util.Arrays") + + context = ContextUtil.getApplicationContext() + work_manager = WorkManager.getInstance(context) + + # Query for ENQUEUED and RUNNING work + states = Arrays.asList(WorkInfo.State.ENQUEUED, WorkInfo.State.RUNNING) + work_query = WorkQuery.fromStates(states) + work_info_list = work_manager.getWorkInfos(work_query).get() + + # Extract Kolibri job IDs from tags + # Tags include both worker class FQNs and our job ID tag set via Task.enqueueOnce + # We use an explicit prefix to identify our tags rather than excluding known patterns + job_ids = set() + for work_info in work_info_list.toArray(): + tags = work_info.getTags() + for tag in tags.toArray(): + if tag.startswith("kolibri:job:"): + job_ids.add(tag[len("kolibri:job:") :]) + + return job_ids + + +def _get_kolibri_active_jobs(): + """ + Get all active jobs from Kolibri database + + Returns: + dict: Mapping of job_id string to job object + """ + active_states = [ + State.PENDING, + State.QUEUED, + State.SCHEDULED, + State.SELECTED, + State.RUNNING, + ] + kolibri_jobs = {} + + for state in active_states: + for job in job_storage.filter_jobs(state=state): + kolibri_jobs[str(job.job_id)] = job + + return kolibri_jobs + + +def _reenqueue_missing_task(job_id, job): + """ + Re-enqueue a single missing task + + Returns: + bool: True if successfully re-enqueued + """ + try: + request_id = Task.enqueueOnce( + job_id, + 0, # delay - immediate + False, # high_priority - use normal for reconciliation + job.func, + job.long_running, + ) + if request_id: + logger.info(f"Re-enqueued missing task: {job_id}") + return True + else: + logger.error(f"Failed to re-enqueue task: {job_id}") + return False + except Exception as e: + logger.error(f"Error re-enqueuing task {job_id}: {e}", exc_info=True) + return False + + +def _cancel_orphaned_task(job_id): + """ + Cancel a single orphaned task + + Returns: + bool: True if successfully cancelled + """ + try: + Task.clear(job_id) + logger.info(f"Cancelled orphaned task: {job_id}") + return True + except Exception as e: + logger.error(f"Error cancelling task {job_id}: {e}", exc_info=True) + return False + + +def _do_reconciliation(): + """ + Internal reconciliation logic + Compares Kolibri database with WorkManager state and reconciles: + - Re-enqueues missing tasks (in Kolibri but not in WorkManager) + - Cancels orphaned tasks (in WorkManager but not in Kolibri) + """ + logger.info("Starting task reconciliation") + + try: + kolibri_jobs = _get_kolibri_active_jobs() + kolibri_job_ids = set(kolibri_jobs.keys()) + logger.info( + f"Found {len(kolibri_job_ids)} active jobs in Kolibri database" + ) + + workmanager_job_ids = _get_workmanager_job_ids() + logger.info( + f"Found {len(workmanager_job_ids)} active tasks in WorkManager" + ) + + # Re-enqueue missing tasks (in Kolibri but not in WorkManager) + missing_job_ids = kolibri_job_ids - workmanager_job_ids + added_count = 0 + if missing_job_ids: + logger.info( + f"Found {len(missing_job_ids)} missing tasks to re-enqueue" + ) + for job_id in missing_job_ids: + job = kolibri_jobs.get(job_id) + if job and _reenqueue_missing_task(job_id, job): + added_count += 1 + + # Cancel orphaned tasks (in WorkManager but not in Kolibri) + orphaned_job_ids = workmanager_job_ids - kolibri_job_ids + cancelled_count = 0 + if orphaned_job_ids: + logger.info( + f"Found {len(orphaned_job_ids)} orphaned tasks to cancel" + ) + for job_id in orphaned_job_ids: + if _cancel_orphaned_task(job_id): + cancelled_count += 1 + + logger.info("Task reconciliation completed") + logger.info(f"Added: {added_count}, Cancelled: {cancelled_count}") + + return {"added": added_count, "cancelled": cancelled_count} + + except Exception as e: + logger.error(f"Error in reconciliation logic: {e}", exc_info=True) + return {"added": 0, "cancelled": 0} + + +def _get_lock_file_path(): + """Get the path for the reconciliation lock file""" + kolibri_home = os.environ.get("KOLIBRI_HOME", "") + return os.path.join(kolibri_home, "kolibri_reconciler.lock") + + +def reconcile_tasks(): + """ + Reconcile WorkManager state with Kolibri database + Called from Java WorkController + + Uses a file-based lock for cross-process safety, since workers + run in a separate process from the main app. + + Returns: + tuple: (added_count, cancelled_count) - reconciliation summary + """ + import fcntl + + lock_file_path = _get_lock_file_path() + lock_fd = None + + try: + lock_fd = os.open(lock_file_path, os.O_CREAT | os.O_RDWR) + # Non-blocking exclusive lock + try: + fcntl.flock(lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB) + except (IOError, OSError): + logger.info("Reconciliation already in progress, skipping") + return (0, 0) + + result = _do_reconciliation() + return (result["added"], result["cancelled"]) + + except Exception as e: + logger.error(f"Error during task reconciliation: {e}", exc_info=True) + return (0, 0) + + finally: + if lock_fd is not None: + try: + fcntl.flock(lock_fd, fcntl.LOCK_UN) + except (IOError, OSError): + pass + os.close(lock_fd) diff --git a/app/src/main/python/taskworker.py b/app/src/main/python/taskworker.py index ea585a7e..e92f2f0a 100644 --- a/app/src/main/python/taskworker.py +++ b/app/src/main/python/taskworker.py @@ -2,40 +2,43 @@ import os import threading -import initialization # noqa: F401 keep this first, to ensure we're set up for other imports -from kolibri.main import enable_plugin -from kolibri.main import initialize - -enable_plugin("android_app_plugin") -initialize(skip_update=True) +from kolibri.core.tasks.worker import execute_job as kolibri_execute_job logger = logging.getLogger(__name__) -def main(job_request): - request_id, job_id, process_id, thread_id = job_request.split(",") +def execute_job(job_id, request_id): + """ + Execute a Kolibri job given its job ID (UUID) + Called from Java TaskWorkerImpl via Chaquopy + + Args: + job_id: The Kolibri job ID (UUID as string) + request_id: The WorkManager request ID (UUID as string), used for debug tracing + + Returns: + bool: True if job executed successfully, False otherwise + """ logger.info( - "Starting Kolibri task worker, for job {} and request {}".format( + "Starting Kolibri task worker for job {} (request {})".format( job_id, request_id ) ) - # Import this after we have initialized Kolibri - from kolibri.core.tasks.worker import execute_job # noqa: E402 - try: - execute_job( + kolibri_execute_job( str(job_id), - worker_process=str(process_id), - worker_thread=str(thread_id), + worker_process=str(os.getpid()), + worker_thread=str(threading.get_ident()), worker_extra=str(request_id), ) - except Exception as e: - logger.exception("Error occurred executing job", exc_info=e) - raise e - - logger.info( - "Ending Kolibri task worker, for job {} and request {}".format( - job_id, request_id + logger.info( + "Completed Kolibri task worker for job {} (request {})".format( + job_id, request_id + ) ) - ) + return True + + except Exception: + logger.exception("Error occurred executing job") + return False diff --git a/app/src/main/res/animator/wing_flap_inner.xml b/app/src/main/res/animator/wing_flap_inner.xml new file mode 100644 index 00000000..b99c826b --- /dev/null +++ b/app/src/main/res/animator/wing_flap_inner.xml @@ -0,0 +1,12 @@ + + + + + + + + + + diff --git a/app/src/main/res/animator/wing_flap_middle.xml b/app/src/main/res/animator/wing_flap_middle.xml new file mode 100644 index 00000000..c871086b --- /dev/null +++ b/app/src/main/res/animator/wing_flap_middle.xml @@ -0,0 +1,12 @@ + + + + + + + + + + diff --git a/app/src/main/res/animator/wing_flap_outer.xml b/app/src/main/res/animator/wing_flap_outer.xml new file mode 100644 index 00000000..c41a246f --- /dev/null +++ b/app/src/main/res/animator/wing_flap_outer.xml @@ -0,0 +1,12 @@ + + + + + + + + + + diff --git a/app/src/main/res/drawable-hdpi/ic_stat_kolibri_notification.png b/app/src/main/res/drawable-hdpi/ic_stat_kolibri_notification.png new file mode 100644 index 0000000000000000000000000000000000000000..b2812b995514e091b147b2ee7e88b6cb48bbc86f GIT binary patch literal 1354 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 X{w$br>GHwz00000NkvXXu0mjf#n+?f literal 0 HcmV?d00001 diff --git a/app/src/main/res/drawable/.gitkeep b/app/src/main/res/drawable/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/app/src/main/res/drawable/baseline_notifications_paused_24.xml b/app/src/main/res/drawable/baseline_notifications_paused_24.xml new file mode 100644 index 00000000..3e64e90d --- /dev/null +++ b/app/src/main/res/drawable/baseline_notifications_paused_24.xml @@ -0,0 +1,5 @@ + + + diff --git a/app/src/main/res/drawable/kolibri_logo.xml b/app/src/main/res/drawable/kolibri_logo.xml new file mode 100644 index 00000000..4cd3f8a5 --- /dev/null +++ b/app/src/main/res/drawable/kolibri_logo.xml @@ -0,0 +1,85 @@ + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/kolibri_logo_animated.xml b/app/src/main/res/drawable/kolibri_logo_animated.xml new file mode 100644 index 00000000..583b31f9 --- /dev/null +++ b/app/src/main/res/drawable/kolibri_logo_animated.xml @@ -0,0 +1,25 @@ + + + + + + + + + + + + diff --git a/app/src/main/res/layout/activity_webview.xml b/app/src/main/res/layout/activity_webview.xml new file mode 100644 index 00000000..b68c1a0d --- /dev/null +++ b/app/src/main/res/layout/activity_webview.xml @@ -0,0 +1,50 @@ + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/mipmap-anydpi-v26/.gitkeep b/app/src/main/res/mipmap-anydpi-v26/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/app/src/main/res/mipmap/.gitkeep b/app/src/main/res/mipmap/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/app/src/main/res/mipmap/icon.png b/app/src/main/res/mipmap/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..dd0e0253de4c6eeb5aeeddd70a4a4ccddec9a2e6 GIT binary patch literal 72353 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 + + #4368F5 + #FFCB00 + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml new file mode 100644 index 00000000..7c163dae --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,13 @@ + + + Kolibri + :task_worker + Task started… + Important Tasks Running + In progress + View task manager + Tasks + Kolibri Task Running + Processing task in background… + Learner + diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml new file mode 100644 index 00000000..ee3d95b6 --- /dev/null +++ b/app/src/main/res/values/themes.xml @@ -0,0 +1,24 @@ + + + + + + + + diff --git a/app/src/main/res/xml/.gitkeep b/app/src/main/res/xml/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/app/src/main/res/xml/file_paths.xml b/app/src/main/res/xml/file_paths.xml new file mode 100644 index 00000000..1a14190b --- /dev/null +++ b/app/src/main/res/xml/file_paths.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/xml/network_security_config.xml b/app/src/main/res/xml/network_security_config.xml new file mode 100644 index 00000000..b4191726 --- /dev/null +++ b/app/src/main/res/xml/network_security_config.xml @@ -0,0 +1,8 @@ + + + + 127.0.0.1 + localhost + + + diff --git a/build.gradle b/build.gradle new file mode 100644 index 00000000..44cfb021 --- /dev/null +++ b/build.gradle @@ -0,0 +1,20 @@ +// Top-level build file where you can add configuration options common to all sub-projects/modules. +buildscript { + repositories { + google() + mavenCentral() + maven { url = "https://chaquo.com/maven" } + } + dependencies { + classpath 'com.android.tools.build:gradle:8.7.3' + classpath 'com.chaquo.python:gradle:17.0.0' + classpath 'com.diffplug.spotless:spotless-plugin-gradle:6.25.0' + } +} + +// Note: Repository configuration moved to settings.gradle (dependencyResolutionManagement) +// This is the Gradle 7.0+ recommended approach + +task clean(type: Delete) { + delete rootProject.buildDir +} diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 00000000..f649c743 --- /dev/null +++ b/gradle.properties @@ -0,0 +1,12 @@ +# Project-wide Gradle settings. +org.gradle.jvmargs=-Xmx4608m -XX:MaxMetaspaceSize=512m -XX:+HeapDumpOnOutOfMemoryError +org.gradle.parallel=true +org.gradle.caching=true + +# AndroidX package structure to make it clearer which packages are bundled with the +# Android operating system, and which are packaged with your app's APK +android.useAndroidX=true +# Automatically convert third-party libraries to use AndroidX +android.enableJetifier=true +# Enable Kotlin support +kotlin.code.style=official diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..077bf263b809a5876104c4efa7213f971f9759e1 GIT binary patch literal 89241 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 F2 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 Fe*l;U3F80& literal 0 HcmV?d00001 diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 00000000..d4081da4 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100755 index 00000000..d016ed49 --- /dev/null +++ b/gradlew @@ -0,0 +1,250 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s +' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 00000000..2f0567cf --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,37 @@ +[project] +name = "kolibri-installer-android" +version = "0.1.0" +requires-python = ">=3.10" +dependencies = [] + +[project.optional-dependencies] +dev = [ + "prek", + "websockets>=16.0", +] +test = [ + "pytest", + "websockets>=16.0", +] +build = [ + "google-api-python-client==2.96.0", + "google-auth==2.22.0", + "google-auth-httplib2==0.1.0", +] + +[tool.ruff] +line-length = 80 + +[tool.ruff.lint] +select = ["E", "F", "W", "C90", "I", "T10"] +ignore = ["E226", "E203", "E501", "E741"] + +[tool.ruff.lint.mccabe] +max-complexity = 10 + +[tool.ruff.lint.isort] +force-single-line = true +known-first-party = ["kolibri"] + +[tool.pytest.ini_options] +testpaths = ["scripts/tests"] diff --git a/requirements.txt b/requirements.txt index f30053da..331a9399 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,8 +1,5 @@ -cython~=0.29 -ifaddr -virtualenv -setuptools -git+https://github.com/learningequality/python-for-android@1b3fff2dd4e9a147551650b2730fcd6e6441f02d#egg=python-for-android -google-api-python-client==2.96.0 -google-auth==2.22.0 -google-auth-httplib2==0.1.0 +# Chaquopy runtime dependencies — kept as a separate requirements.txt because +# Chaquopy's Gradle plugin reads this file directly via pip { install "-r" }. +# Pinned to versions with prebuilt Chaquopy wheels. +cryptography==42.0.8 +ifaddr==0.2.0 diff --git a/settings.gradle b/settings.gradle new file mode 100644 index 00000000..3103a21b --- /dev/null +++ b/settings.gradle @@ -0,0 +1,19 @@ +pluginManagement { + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + maven { url = "https://chaquo.com/maven" } + } +} + +rootProject.name = "Kolibri" +include ':app' diff --git a/uv.lock b/uv.lock new file mode 100644 index 00000000..ed2efe97 --- /dev/null +++ b/uv.lock @@ -0,0 +1,231 @@ +version = 1 +revision = 3 +requires-python = ">=3.10" + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, +] + +[[package]] +name = "exceptiongroup" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740, upload-time = "2025-11-21T23:01:53.443Z" }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + +[[package]] +name = "kolibri-installer-android" +version = "0.1.0" +source = { virtual = "." } + +[package.optional-dependencies] +dev = [ + { name = "websockets" }, +] +test = [ + { name = "pytest" }, + { name = "websockets" }, +] + +[package.metadata] +requires-dist = [ + { name = "pytest", marker = "extra == 'test'" }, + { name = "websockets", marker = "extra == 'dev'", specifier = ">=16.0" }, + { name = "websockets", marker = "extra == 'test'", specifier = ">=16.0" }, +] +provides-extras = ["dev", "test"] + +[[package]] +name = "packaging" +version = "26.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/65/ee/299d360cdc32edc7d2cf530f3accf79c4fca01e96ffc950d8a52213bd8e4/packaging-26.0.tar.gz", hash = "sha256:00243ae351a257117b6a241061796684b084ed1c516a08c48a3f7e147a9d80b4", size = 143416, upload-time = "2026-01-21T20:50:39.064Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/b9/c538f279a4e237a006a2c98387d081e9eb060d203d8ed34467cc0f0b9b53/packaging-26.0-py3-none-any.whl", hash = "sha256:b36f1fef9334a5588b4166f8bcd26a14e521f2b55e6b9de3aaa80d3ff7a37529", size = 74366, upload-time = "2026-01-21T20:50:37.788Z" }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + +[[package]] +name = "pygments" +version = "2.19.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" }, +] + +[[package]] +name = "pytest" +version = "9.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d1/db/7ef3487e0fb0049ddb5ce41d3a49c235bf9ad299b6a25d5780a89f19230f/pytest-9.0.2.tar.gz", hash = "sha256:75186651a92bd89611d1d9fc20f0b4345fd827c41ccd5c299a868a05d70edf11", size = 1568901, upload-time = "2025-12-06T21:30:51.014Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3b/ab/b3226f0bd7cdcf710fbede2b3548584366da3b19b5021e74f5bde2a8fa3f/pytest-9.0.2-py3-none-any.whl", hash = "sha256:711ffd45bf766d5264d487b917733b453d917afd2b0ad65223959f59089f875b", size = 374801, upload-time = "2025-12-06T21:30:49.154Z" }, +] + +[[package]] +name = "tomli" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/30/31573e9457673ab10aa432461bee537ce6cef177667deca369efb79df071/tomli-2.4.0.tar.gz", hash = "sha256:aa89c3f6c277dd275d8e243ad24f3b5e701491a860d5121f2cdd399fbb31fc9c", size = 17477, upload-time = "2026-01-11T11:22:38.165Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3c/d9/3dc2289e1f3b32eb19b9785b6a006b28ee99acb37d1d47f78d4c10e28bf8/tomli-2.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b5ef256a3fd497d4973c11bf142e9ed78b150d36f5773f1ca6088c230ffc5867", size = 153663, upload-time = "2026-01-11T11:21:45.27Z" }, + { url = "https://files.pythonhosted.org/packages/51/32/ef9f6845e6b9ca392cd3f64f9ec185cc6f09f0a2df3db08cbe8809d1d435/tomli-2.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5572e41282d5268eb09a697c89a7bee84fae66511f87533a6f88bd2f7b652da9", size = 148469, upload-time = "2026-01-11T11:21:46.873Z" }, + { url = "https://files.pythonhosted.org/packages/d6/c2/506e44cce89a8b1b1e047d64bd495c22c9f71f21e05f380f1a950dd9c217/tomli-2.4.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:551e321c6ba03b55676970b47cb1b73f14a0a4dce6a3e1a9458fd6d921d72e95", size = 236039, upload-time = "2026-01-11T11:21:48.503Z" }, + { url = "https://files.pythonhosted.org/packages/b3/40/e1b65986dbc861b7e986e8ec394598187fa8aee85b1650b01dd925ca0be8/tomli-2.4.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e3f639a7a8f10069d0e15408c0b96a2a828cfdec6fca05296ebcdcc28ca7c76", size = 243007, upload-time = "2026-01-11T11:21:49.456Z" }, + { url = "https://files.pythonhosted.org/packages/9c/6f/6e39ce66b58a5b7ae572a0f4352ff40c71e8573633deda43f6a379d56b3e/tomli-2.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1b168f2731796b045128c45982d3a4874057626da0e2ef1fdd722848b741361d", size = 240875, upload-time = "2026-01-11T11:21:50.755Z" }, + { url = "https://files.pythonhosted.org/packages/aa/ad/cb089cb190487caa80204d503c7fd0f4d443f90b95cf4ef5cf5aa0f439b0/tomli-2.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:133e93646ec4300d651839d382d63edff11d8978be23da4cc106f5a18b7d0576", size = 246271, upload-time = "2026-01-11T11:21:51.81Z" }, + { url = "https://files.pythonhosted.org/packages/0b/63/69125220e47fd7a3a27fd0de0c6398c89432fec41bc739823bcc66506af6/tomli-2.4.0-cp311-cp311-win32.whl", hash = "sha256:b6c78bdf37764092d369722d9946cb65b8767bfa4110f902a1b2542d8d173c8a", size = 96770, upload-time = "2026-01-11T11:21:52.647Z" }, + { url = "https://files.pythonhosted.org/packages/1e/0d/a22bb6c83f83386b0008425a6cd1fa1c14b5f3dd4bad05e98cf3dbbf4a64/tomli-2.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:d3d1654e11d724760cdb37a3d7691f0be9db5fbdaef59c9f532aabf87006dbaa", size = 107626, upload-time = "2026-01-11T11:21:53.459Z" }, + { url = "https://files.pythonhosted.org/packages/2f/6d/77be674a3485e75cacbf2ddba2b146911477bd887dda9d8c9dfb2f15e871/tomli-2.4.0-cp311-cp311-win_arm64.whl", hash = "sha256:cae9c19ed12d4e8f3ebf46d1a75090e4c0dc16271c5bce1c833ac168f08fb614", size = 94842, upload-time = "2026-01-11T11:21:54.831Z" }, + { url = "https://files.pythonhosted.org/packages/3c/43/7389a1869f2f26dba52404e1ef13b4784b6b37dac93bac53457e3ff24ca3/tomli-2.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:920b1de295e72887bafa3ad9f7a792f811847d57ea6b1215154030cf131f16b1", size = 154894, upload-time = "2026-01-11T11:21:56.07Z" }, + { url = "https://files.pythonhosted.org/packages/e9/05/2f9bf110b5294132b2edf13fe6ca6ae456204f3d749f623307cbb7a946f2/tomli-2.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7d6d9a4aee98fac3eab4952ad1d73aee87359452d1c086b5ceb43ed02ddb16b8", size = 149053, upload-time = "2026-01-11T11:21:57.467Z" }, + { url = "https://files.pythonhosted.org/packages/e8/41/1eda3ca1abc6f6154a8db4d714a4d35c4ad90adc0bcf700657291593fbf3/tomli-2.4.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:36b9d05b51e65b254ea6c2585b59d2c4cb91c8a3d91d0ed0f17591a29aaea54a", size = 243481, upload-time = "2026-01-11T11:21:58.661Z" }, + { url = "https://files.pythonhosted.org/packages/d2/6d/02ff5ab6c8868b41e7d4b987ce2b5f6a51d3335a70aa144edd999e055a01/tomli-2.4.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c8a885b370751837c029ef9bc014f27d80840e48bac415f3412e6593bbc18c1", size = 251720, upload-time = "2026-01-11T11:22:00.178Z" }, + { url = "https://files.pythonhosted.org/packages/7b/57/0405c59a909c45d5b6f146107c6d997825aa87568b042042f7a9c0afed34/tomli-2.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8768715ffc41f0008abe25d808c20c3d990f42b6e2e58305d5da280ae7d1fa3b", size = 247014, upload-time = "2026-01-11T11:22:01.238Z" }, + { url = "https://files.pythonhosted.org/packages/2c/0e/2e37568edd944b4165735687cbaf2fe3648129e440c26d02223672ee0630/tomli-2.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:7b438885858efd5be02a9a133caf5812b8776ee0c969fea02c45e8e3f296ba51", size = 251820, upload-time = "2026-01-11T11:22:02.727Z" }, + { url = "https://files.pythonhosted.org/packages/5a/1c/ee3b707fdac82aeeb92d1a113f803cf6d0f37bdca0849cb489553e1f417a/tomli-2.4.0-cp312-cp312-win32.whl", hash = "sha256:0408e3de5ec77cc7f81960c362543cbbd91ef883e3138e81b729fc3eea5b9729", size = 97712, upload-time = "2026-01-11T11:22:03.777Z" }, + { url = "https://files.pythonhosted.org/packages/69/13/c07a9177d0b3bab7913299b9278845fc6eaaca14a02667c6be0b0a2270c8/tomli-2.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:685306e2cc7da35be4ee914fd34ab801a6acacb061b6a7abca922aaf9ad368da", size = 108296, upload-time = "2026-01-11T11:22:04.86Z" }, + { url = "https://files.pythonhosted.org/packages/18/27/e267a60bbeeee343bcc279bb9e8fbed0cbe224bc7b2a3dc2975f22809a09/tomli-2.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:5aa48d7c2356055feef06a43611fc401a07337d5b006be13a30f6c58f869e3c3", size = 94553, upload-time = "2026-01-11T11:22:05.854Z" }, + { url = "https://files.pythonhosted.org/packages/34/91/7f65f9809f2936e1f4ce6268ae1903074563603b2a2bd969ebbda802744f/tomli-2.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:84d081fbc252d1b6a982e1870660e7330fb8f90f676f6e78b052ad4e64714bf0", size = 154915, upload-time = "2026-01-11T11:22:06.703Z" }, + { url = "https://files.pythonhosted.org/packages/20/aa/64dd73a5a849c2e8f216b755599c511badde80e91e9bc2271baa7b2cdbb1/tomli-2.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:9a08144fa4cba33db5255f9b74f0b89888622109bd2776148f2597447f92a94e", size = 149038, upload-time = "2026-01-11T11:22:07.56Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8a/6d38870bd3d52c8d1505ce054469a73f73a0fe62c0eaf5dddf61447e32fa/tomli-2.4.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c73add4bb52a206fd0c0723432db123c0c75c280cbd67174dd9d2db228ebb1b4", size = 242245, upload-time = "2026-01-11T11:22:08.344Z" }, + { url = "https://files.pythonhosted.org/packages/59/bb/8002fadefb64ab2669e5b977df3f5e444febea60e717e755b38bb7c41029/tomli-2.4.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fb2945cbe303b1419e2706e711b7113da57b7db31ee378d08712d678a34e51e", size = 250335, upload-time = "2026-01-11T11:22:09.951Z" }, + { url = "https://files.pythonhosted.org/packages/a5/3d/4cdb6f791682b2ea916af2de96121b3cb1284d7c203d97d92d6003e91c8d/tomli-2.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bbb1b10aa643d973366dc2cb1ad94f99c1726a02343d43cbc011edbfac579e7c", size = 245962, upload-time = "2026-01-11T11:22:11.27Z" }, + { url = "https://files.pythonhosted.org/packages/f2/4a/5f25789f9a460bd858ba9756ff52d0830d825b458e13f754952dd15fb7bb/tomli-2.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4cbcb367d44a1f0c2be408758b43e1ffb5308abe0ea222897d6bfc8e8281ef2f", size = 250396, upload-time = "2026-01-11T11:22:12.325Z" }, + { url = "https://files.pythonhosted.org/packages/aa/2f/b73a36fea58dfa08e8b3a268750e6853a6aac2a349241a905ebd86f3047a/tomli-2.4.0-cp313-cp313-win32.whl", hash = "sha256:7d49c66a7d5e56ac959cb6fc583aff0651094ec071ba9ad43df785abc2320d86", size = 97530, upload-time = "2026-01-11T11:22:13.865Z" }, + { url = "https://files.pythonhosted.org/packages/3b/af/ca18c134b5d75de7e8dc551c5234eaba2e8e951f6b30139599b53de9c187/tomli-2.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:3cf226acb51d8f1c394c1b310e0e0e61fecdd7adcb78d01e294ac297dd2e7f87", size = 108227, upload-time = "2026-01-11T11:22:15.224Z" }, + { url = "https://files.pythonhosted.org/packages/22/c3/b386b832f209fee8073c8138ec50f27b4460db2fdae9ffe022df89a57f9b/tomli-2.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:d20b797a5c1ad80c516e41bc1fb0443ddb5006e9aaa7bda2d71978346aeb9132", size = 94748, upload-time = "2026-01-11T11:22:16.009Z" }, + { url = "https://files.pythonhosted.org/packages/f3/c4/84047a97eb1004418bc10bdbcfebda209fca6338002eba2dc27cc6d13563/tomli-2.4.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:26ab906a1eb794cd4e103691daa23d95c6919cc2fa9160000ac02370cc9dd3f6", size = 154725, upload-time = "2026-01-11T11:22:17.269Z" }, + { url = "https://files.pythonhosted.org/packages/a8/5d/d39038e646060b9d76274078cddf146ced86dc2b9e8bbf737ad5983609a0/tomli-2.4.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:20cedb4ee43278bc4f2fee6cb50daec836959aadaf948db5172e776dd3d993fc", size = 148901, upload-time = "2026-01-11T11:22:18.287Z" }, + { url = "https://files.pythonhosted.org/packages/73/e5/383be1724cb30f4ce44983d249645684a48c435e1cd4f8b5cded8a816d3c/tomli-2.4.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:39b0b5d1b6dd03684b3fb276407ebed7090bbec989fa55838c98560c01113b66", size = 243375, upload-time = "2026-01-11T11:22:19.154Z" }, + { url = "https://files.pythonhosted.org/packages/31/f0/bea80c17971c8d16d3cc109dc3585b0f2ce1036b5f4a8a183789023574f2/tomli-2.4.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a26d7ff68dfdb9f87a016ecfd1e1c2bacbe3108f4e0f8bcd2228ef9a766c787d", size = 250639, upload-time = "2026-01-11T11:22:20.168Z" }, + { url = "https://files.pythonhosted.org/packages/2c/8f/2853c36abbb7608e3f945d8a74e32ed3a74ee3a1f468f1ffc7d1cb3abba6/tomli-2.4.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:20ffd184fb1df76a66e34bd1b36b4a4641bd2b82954befa32fe8163e79f1a702", size = 246897, upload-time = "2026-01-11T11:22:21.544Z" }, + { url = "https://files.pythonhosted.org/packages/49/f0/6c05e3196ed5337b9fe7ea003e95fd3819a840b7a0f2bf5a408ef1dad8ed/tomli-2.4.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:75c2f8bbddf170e8effc98f5e9084a8751f8174ea6ccf4fca5398436e0320bc8", size = 254697, upload-time = "2026-01-11T11:22:23.058Z" }, + { url = "https://files.pythonhosted.org/packages/f3/f5/2922ef29c9f2951883525def7429967fc4d8208494e5ab524234f06b688b/tomli-2.4.0-cp314-cp314-win32.whl", hash = "sha256:31d556d079d72db7c584c0627ff3a24c5d3fb4f730221d3444f3efb1b2514776", size = 98567, upload-time = "2026-01-11T11:22:24.033Z" }, + { url = "https://files.pythonhosted.org/packages/7b/31/22b52e2e06dd2a5fdbc3ee73226d763b184ff21fc24e20316a44ccc4d96b/tomli-2.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:43e685b9b2341681907759cf3a04e14d7104b3580f808cfde1dfdb60ada85475", size = 108556, upload-time = "2026-01-11T11:22:25.378Z" }, + { url = "https://files.pythonhosted.org/packages/48/3d/5058dff3255a3d01b705413f64f4306a141a8fd7a251e5a495e3f192a998/tomli-2.4.0-cp314-cp314-win_arm64.whl", hash = "sha256:3d895d56bd3f82ddd6faaff993c275efc2ff38e52322ea264122d72729dca2b2", size = 96014, upload-time = "2026-01-11T11:22:26.138Z" }, + { url = "https://files.pythonhosted.org/packages/b8/4e/75dab8586e268424202d3a1997ef6014919c941b50642a1682df43204c22/tomli-2.4.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:5b5807f3999fb66776dbce568cc9a828544244a8eb84b84b9bafc080c99597b9", size = 163339, upload-time = "2026-01-11T11:22:27.143Z" }, + { url = "https://files.pythonhosted.org/packages/06/e3/b904d9ab1016829a776d97f163f183a48be6a4deb87304d1e0116a349519/tomli-2.4.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c084ad935abe686bd9c898e62a02a19abfc9760b5a79bc29644463eaf2840cb0", size = 159490, upload-time = "2026-01-11T11:22:28.399Z" }, + { url = "https://files.pythonhosted.org/packages/e3/5a/fc3622c8b1ad823e8ea98a35e3c632ee316d48f66f80f9708ceb4f2a0322/tomli-2.4.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f2e3955efea4d1cfbcb87bc321e00dc08d2bcb737fd1d5e398af111d86db5df", size = 269398, upload-time = "2026-01-11T11:22:29.345Z" }, + { url = "https://files.pythonhosted.org/packages/fd/33/62bd6152c8bdd4c305ad9faca48f51d3acb2df1f8791b1477d46ff86e7f8/tomli-2.4.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e0fe8a0b8312acf3a88077a0802565cb09ee34107813bba1c7cd591fa6cfc8d", size = 276515, upload-time = "2026-01-11T11:22:30.327Z" }, + { url = "https://files.pythonhosted.org/packages/4b/ff/ae53619499f5235ee4211e62a8d7982ba9e439a0fb4f2f351a93d67c1dd2/tomli-2.4.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:413540dce94673591859c4c6f794dfeaa845e98bf35d72ed59636f869ef9f86f", size = 273806, upload-time = "2026-01-11T11:22:32.56Z" }, + { url = "https://files.pythonhosted.org/packages/47/71/cbca7787fa68d4d0a9f7072821980b39fbb1b6faeb5f5cf02f4a5559fa28/tomli-2.4.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0dc56fef0e2c1c470aeac5b6ca8cc7b640bb93e92d9803ddaf9ea03e198f5b0b", size = 281340, upload-time = "2026-01-11T11:22:33.505Z" }, + { url = "https://files.pythonhosted.org/packages/f5/00/d595c120963ad42474cf6ee7771ad0d0e8a49d0f01e29576ee9195d9ecdf/tomli-2.4.0-cp314-cp314t-win32.whl", hash = "sha256:d878f2a6707cc9d53a1be1414bbb419e629c3d6e67f69230217bb663e76b5087", size = 108106, upload-time = "2026-01-11T11:22:34.451Z" }, + { url = "https://files.pythonhosted.org/packages/de/69/9aa0c6a505c2f80e519b43764f8b4ba93b5a0bbd2d9a9de6e2b24271b9a5/tomli-2.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2add28aacc7425117ff6364fe9e06a183bb0251b03f986df0e78e974047571fd", size = 120504, upload-time = "2026-01-11T11:22:35.764Z" }, + { url = "https://files.pythonhosted.org/packages/b3/9f/f1668c281c58cfae01482f7114a4b88d345e4c140386241a1a24dcc9e7bc/tomli-2.4.0-cp314-cp314t-win_arm64.whl", hash = "sha256:2b1e3b80e1d5e52e40e9b924ec43d81570f0e7d09d11081b797bc4692765a3d4", size = 99561, upload-time = "2026-01-11T11:22:36.624Z" }, + { url = "https://files.pythonhosted.org/packages/23/d1/136eb2cb77520a31e1f64cbae9d33ec6df0d78bdf4160398e86eec8a8754/tomli-2.4.0-py3-none-any.whl", hash = "sha256:1f776e7d669ebceb01dee46484485f43a4048746235e683bcdffacdf1fb4785a", size = 14477, upload-time = "2026-01-11T11:22:37.446Z" }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + +[[package]] +name = "websockets" +version = "16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5", size = 179346, upload-time = "2026-01-10T09:23:47.181Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/74/221f58decd852f4b59cc3354cccaf87e8ef695fede361d03dc9a7396573b/websockets-16.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:04cdd5d2d1dacbad0a7bf36ccbcd3ccd5a30ee188f2560b7a62a30d14107b31a", size = 177343, upload-time = "2026-01-10T09:22:21.28Z" }, + { url = "https://files.pythonhosted.org/packages/19/0f/22ef6107ee52ab7f0b710d55d36f5a5d3ef19e8a205541a6d7ffa7994e5a/websockets-16.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:8ff32bb86522a9e5e31439a58addbb0166f0204d64066fb955265c4e214160f0", size = 175021, upload-time = "2026-01-10T09:22:22.696Z" }, + { url = "https://files.pythonhosted.org/packages/10/40/904a4cb30d9b61c0e278899bf36342e9b0208eb3c470324a9ecbaac2a30f/websockets-16.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:583b7c42688636f930688d712885cf1531326ee05effd982028212ccc13e5957", size = 175320, upload-time = "2026-01-10T09:22:23.94Z" }, + { url = "https://files.pythonhosted.org/packages/9d/2f/4b3ca7e106bc608744b1cdae041e005e446124bebb037b18799c2d356864/websockets-16.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7d837379b647c0c4c2355c2499723f82f1635fd2c26510e1f587d89bc2199e72", size = 183815, upload-time = "2026-01-10T09:22:25.469Z" }, + { url = "https://files.pythonhosted.org/packages/86/26/d40eaa2a46d4302becec8d15b0fc5e45bdde05191e7628405a19cf491ccd/websockets-16.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df57afc692e517a85e65b72e165356ed1df12386ecb879ad5693be08fac65dde", size = 185054, upload-time = "2026-01-10T09:22:27.101Z" }, + { url = "https://files.pythonhosted.org/packages/b0/ba/6500a0efc94f7373ee8fefa8c271acdfd4dca8bd49a90d4be7ccabfc397e/websockets-16.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:2b9f1e0d69bc60a4a87349d50c09a037a2607918746f07de04df9e43252c77a3", size = 184565, upload-time = "2026-01-10T09:22:28.293Z" }, + { url = "https://files.pythonhosted.org/packages/04/b4/96bf2cee7c8d8102389374a2616200574f5f01128d1082f44102140344cc/websockets-16.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:335c23addf3d5e6a8633f9f8eda77efad001671e80b95c491dd0924587ece0b3", size = 183848, upload-time = "2026-01-10T09:22:30.394Z" }, + { url = "https://files.pythonhosted.org/packages/02/8e/81f40fb00fd125357814e8c3025738fc4ffc3da4b6b4a4472a82ba304b41/websockets-16.0-cp310-cp310-win32.whl", hash = "sha256:37b31c1623c6605e4c00d466c9d633f9b812ea430c11c8a278774a1fde1acfa9", size = 178249, upload-time = "2026-01-10T09:22:32.083Z" }, + { url = "https://files.pythonhosted.org/packages/b4/5f/7e40efe8df57db9b91c88a43690ac66f7b7aa73a11aa6a66b927e44f26fa/websockets-16.0-cp310-cp310-win_amd64.whl", hash = "sha256:8e1dab317b6e77424356e11e99a432b7cb2f3ec8c5ab4dabbcee6add48f72b35", size = 178685, upload-time = "2026-01-10T09:22:33.345Z" }, + { url = "https://files.pythonhosted.org/packages/f2/db/de907251b4ff46ae804ad0409809504153b3f30984daf82a1d84a9875830/websockets-16.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:31a52addea25187bde0797a97d6fc3d2f92b6f72a9370792d65a6e84615ac8a8", size = 177340, upload-time = "2026-01-10T09:22:34.539Z" }, + { url = "https://files.pythonhosted.org/packages/f3/fa/abe89019d8d8815c8781e90d697dec52523fb8ebe308bf11664e8de1877e/websockets-16.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:417b28978cdccab24f46400586d128366313e8a96312e4b9362a4af504f3bbad", size = 175022, upload-time = "2026-01-10T09:22:36.332Z" }, + { url = "https://files.pythonhosted.org/packages/58/5d/88ea17ed1ded2079358b40d31d48abe90a73c9e5819dbcde1606e991e2ad/websockets-16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:af80d74d4edfa3cb9ed973a0a5ba2b2a549371f8a741e0800cb07becdd20f23d", size = 175319, upload-time = "2026-01-10T09:22:37.602Z" }, + { url = "https://files.pythonhosted.org/packages/d2/ae/0ee92b33087a33632f37a635e11e1d99d429d3d323329675a6022312aac2/websockets-16.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:08d7af67b64d29823fed316505a89b86705f2b7981c07848fb5e3ea3020c1abe", size = 184631, upload-time = "2026-01-10T09:22:38.789Z" }, + { url = "https://files.pythonhosted.org/packages/c8/c5/27178df583b6c5b31b29f526ba2da5e2f864ecc79c99dae630a85d68c304/websockets-16.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7be95cfb0a4dae143eaed2bcba8ac23f4892d8971311f1b06f3c6b78952ee70b", size = 185870, upload-time = "2026-01-10T09:22:39.893Z" }, + { url = "https://files.pythonhosted.org/packages/87/05/536652aa84ddc1c018dbb7e2c4cbcd0db884580bf8e95aece7593fde526f/websockets-16.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d6297ce39ce5c2e6feb13c1a996a2ded3b6832155fcfc920265c76f24c7cceb5", size = 185361, upload-time = "2026-01-10T09:22:41.016Z" }, + { url = "https://files.pythonhosted.org/packages/6d/e2/d5332c90da12b1e01f06fb1b85c50cfc489783076547415bf9f0a659ec19/websockets-16.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1c1b30e4f497b0b354057f3467f56244c603a79c0d1dafce1d16c283c25f6e64", size = 184615, upload-time = "2026-01-10T09:22:42.442Z" }, + { url = "https://files.pythonhosted.org/packages/77/fb/d3f9576691cae9253b51555f841bc6600bf0a983a461c79500ace5a5b364/websockets-16.0-cp311-cp311-win32.whl", hash = "sha256:5f451484aeb5cafee1ccf789b1b66f535409d038c56966d6101740c1614b86c6", size = 178246, upload-time = "2026-01-10T09:22:43.654Z" }, + { url = "https://files.pythonhosted.org/packages/54/67/eaff76b3dbaf18dcddabc3b8c1dba50b483761cccff67793897945b37408/websockets-16.0-cp311-cp311-win_amd64.whl", hash = "sha256:8d7f0659570eefb578dacde98e24fb60af35350193e4f56e11190787bee77dac", size = 178684, upload-time = "2026-01-10T09:22:44.941Z" }, + { url = "https://files.pythonhosted.org/packages/84/7b/bac442e6b96c9d25092695578dda82403c77936104b5682307bd4deb1ad4/websockets-16.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:71c989cbf3254fbd5e84d3bff31e4da39c43f884e64f2551d14bb3c186230f00", size = 177365, upload-time = "2026-01-10T09:22:46.787Z" }, + { url = "https://files.pythonhosted.org/packages/b0/fe/136ccece61bd690d9c1f715baaeefd953bb2360134de73519d5df19d29ca/websockets-16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8b6e209ffee39ff1b6d0fa7bfef6de950c60dfb91b8fcead17da4ee539121a79", size = 175038, upload-time = "2026-01-10T09:22:47.999Z" }, + { url = "https://files.pythonhosted.org/packages/40/1e/9771421ac2286eaab95b8575b0cb701ae3663abf8b5e1f64f1fd90d0a673/websockets-16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:86890e837d61574c92a97496d590968b23c2ef0aeb8a9bc9421d174cd378ae39", size = 175328, upload-time = "2026-01-10T09:22:49.809Z" }, + { url = "https://files.pythonhosted.org/packages/18/29/71729b4671f21e1eaa5d6573031ab810ad2936c8175f03f97f3ff164c802/websockets-16.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9b5aca38b67492ef518a8ab76851862488a478602229112c4b0d58d63a7a4d5c", size = 184915, upload-time = "2026-01-10T09:22:51.071Z" }, + { url = "https://files.pythonhosted.org/packages/97/bb/21c36b7dbbafc85d2d480cd65df02a1dc93bf76d97147605a8e27ff9409d/websockets-16.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e0334872c0a37b606418ac52f6ab9cfd17317ac26365f7f65e203e2d0d0d359f", size = 186152, upload-time = "2026-01-10T09:22:52.224Z" }, + { url = "https://files.pythonhosted.org/packages/4a/34/9bf8df0c0cf88fa7bfe36678dc7b02970c9a7d5e065a3099292db87b1be2/websockets-16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a0b31e0b424cc6b5a04b8838bbaec1688834b2383256688cf47eb97412531da1", size = 185583, upload-time = "2026-01-10T09:22:53.443Z" }, + { url = "https://files.pythonhosted.org/packages/47/88/4dd516068e1a3d6ab3c7c183288404cd424a9a02d585efbac226cb61ff2d/websockets-16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:485c49116d0af10ac698623c513c1cc01c9446c058a4e61e3bf6c19dff7335a2", size = 184880, upload-time = "2026-01-10T09:22:55.033Z" }, + { url = "https://files.pythonhosted.org/packages/91/d6/7d4553ad4bf1c0421e1ebd4b18de5d9098383b5caa1d937b63df8d04b565/websockets-16.0-cp312-cp312-win32.whl", hash = "sha256:eaded469f5e5b7294e2bdca0ab06becb6756ea86894a47806456089298813c89", size = 178261, upload-time = "2026-01-10T09:22:56.251Z" }, + { url = "https://files.pythonhosted.org/packages/c3/f0/f3a17365441ed1c27f850a80b2bc680a0fa9505d733fe152fdf5e98c1c0b/websockets-16.0-cp312-cp312-win_amd64.whl", hash = "sha256:5569417dc80977fc8c2d43a86f78e0a5a22fee17565d78621b6bb264a115d4ea", size = 178693, upload-time = "2026-01-10T09:22:57.478Z" }, + { url = "https://files.pythonhosted.org/packages/cc/9c/baa8456050d1c1b08dd0ec7346026668cbc6f145ab4e314d707bb845bf0d/websockets-16.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:878b336ac47938b474c8f982ac2f7266a540adc3fa4ad74ae96fea9823a02cc9", size = 177364, upload-time = "2026-01-10T09:22:59.333Z" }, + { url = "https://files.pythonhosted.org/packages/7e/0c/8811fc53e9bcff68fe7de2bcbe75116a8d959ac699a3200f4847a8925210/websockets-16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:52a0fec0e6c8d9a784c2c78276a48a2bdf099e4ccc2a4cad53b27718dbfd0230", size = 175039, upload-time = "2026-01-10T09:23:01.171Z" }, + { url = "https://files.pythonhosted.org/packages/aa/82/39a5f910cb99ec0b59e482971238c845af9220d3ab9fa76dd9162cda9d62/websockets-16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e6578ed5b6981005df1860a56e3617f14a6c307e6a71b4fff8c48fdc50f3ed2c", size = 175323, upload-time = "2026-01-10T09:23:02.341Z" }, + { url = "https://files.pythonhosted.org/packages/bd/28/0a25ee5342eb5d5f297d992a77e56892ecb65e7854c7898fb7d35e9b33bd/websockets-16.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:95724e638f0f9c350bb1c2b0a7ad0e83d9cc0c9259f3ea94e40d7b02a2179ae5", size = 184975, upload-time = "2026-01-10T09:23:03.756Z" }, + { url = "https://files.pythonhosted.org/packages/f9/66/27ea52741752f5107c2e41fda05e8395a682a1e11c4e592a809a90c6a506/websockets-16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0204dc62a89dc9d50d682412c10b3542d748260d743500a85c13cd1ee4bde82", size = 186203, upload-time = "2026-01-10T09:23:05.01Z" }, + { url = "https://files.pythonhosted.org/packages/37/e5/8e32857371406a757816a2b471939d51c463509be73fa538216ea52b792a/websockets-16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:52ac480f44d32970d66763115edea932f1c5b1312de36df06d6b219f6741eed8", size = 185653, upload-time = "2026-01-10T09:23:06.301Z" }, + { url = "https://files.pythonhosted.org/packages/9b/67/f926bac29882894669368dc73f4da900fcdf47955d0a0185d60103df5737/websockets-16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6e5a82b677f8f6f59e8dfc34ec06ca6b5b48bc4fcda346acd093694cc2c24d8f", size = 184920, upload-time = "2026-01-10T09:23:07.492Z" }, + { url = "https://files.pythonhosted.org/packages/3c/a1/3d6ccdcd125b0a42a311bcd15a7f705d688f73b2a22d8cf1c0875d35d34a/websockets-16.0-cp313-cp313-win32.whl", hash = "sha256:abf050a199613f64c886ea10f38b47770a65154dc37181bfaff70c160f45315a", size = 178255, upload-time = "2026-01-10T09:23:09.245Z" }, + { url = "https://files.pythonhosted.org/packages/6b/ae/90366304d7c2ce80f9b826096a9e9048b4bb760e44d3b873bb272cba696b/websockets-16.0-cp313-cp313-win_amd64.whl", hash = "sha256:3425ac5cf448801335d6fdc7ae1eb22072055417a96cc6b31b3861f455fbc156", size = 178689, upload-time = "2026-01-10T09:23:10.483Z" }, + { url = "https://files.pythonhosted.org/packages/f3/1d/e88022630271f5bd349ed82417136281931e558d628dd52c4d8621b4a0b2/websockets-16.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0", size = 177406, upload-time = "2026-01-10T09:23:12.178Z" }, + { url = "https://files.pythonhosted.org/packages/f2/78/e63be1bf0724eeb4616efb1ae1c9044f7c3953b7957799abb5915bffd38e/websockets-16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:daa3b6ff70a9241cf6c7fc9e949d41232d9d7d26fd3522b1ad2b4d62487e9904", size = 175085, upload-time = "2026-01-10T09:23:13.511Z" }, + { url = "https://files.pythonhosted.org/packages/bb/f4/d3c9220d818ee955ae390cf319a7c7a467beceb24f05ee7aaaa2414345ba/websockets-16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4", size = 175328, upload-time = "2026-01-10T09:23:14.727Z" }, + { url = "https://files.pythonhosted.org/packages/63/bc/d3e208028de777087e6fb2b122051a6ff7bbcca0d6df9d9c2bf1dd869ae9/websockets-16.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:781caf5e8eee67f663126490c2f96f40906594cb86b408a703630f95550a8c3e", size = 185044, upload-time = "2026-01-10T09:23:15.939Z" }, + { url = "https://files.pythonhosted.org/packages/ad/6e/9a0927ac24bd33a0a9af834d89e0abc7cfd8e13bed17a86407a66773cc0e/websockets-16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caab51a72c51973ca21fa8a18bd8165e1a0183f1ac7066a182ff27107b71e1a4", size = 186279, upload-time = "2026-01-10T09:23:17.148Z" }, + { url = "https://files.pythonhosted.org/packages/b9/ca/bf1c68440d7a868180e11be653c85959502efd3a709323230314fda6e0b3/websockets-16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19c4dc84098e523fd63711e563077d39e90ec6702aff4b5d9e344a60cb3c0cb1", size = 185711, upload-time = "2026-01-10T09:23:18.372Z" }, + { url = "https://files.pythonhosted.org/packages/c4/f8/fdc34643a989561f217bb477cbc47a3a07212cbda91c0e4389c43c296ebf/websockets-16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5e18a238a2b2249c9a9235466b90e96ae4795672598a58772dd806edc7ac6d3", size = 184982, upload-time = "2026-01-10T09:23:19.652Z" }, + { url = "https://files.pythonhosted.org/packages/dd/d1/574fa27e233764dbac9c52730d63fcf2823b16f0856b3329fc6268d6ae4f/websockets-16.0-cp314-cp314-win32.whl", hash = "sha256:a069d734c4a043182729edd3e9f247c3b2a4035415a9172fd0f1b71658a320a8", size = 177915, upload-time = "2026-01-10T09:23:21.458Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f1/ae6b937bf3126b5134ce1f482365fde31a357c784ac51852978768b5eff4/websockets-16.0-cp314-cp314-win_amd64.whl", hash = "sha256:c0ee0e63f23914732c6d7e0cce24915c48f3f1512ec1d079ed01fc629dab269d", size = 178381, upload-time = "2026-01-10T09:23:22.715Z" }, + { url = "https://files.pythonhosted.org/packages/06/9b/f791d1db48403e1f0a27577a6beb37afae94254a8c6f08be4a23e4930bc0/websockets-16.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a35539cacc3febb22b8f4d4a99cc79b104226a756aa7400adc722e83b0d03244", size = 177737, upload-time = "2026-01-10T09:23:24.523Z" }, + { url = "https://files.pythonhosted.org/packages/bd/40/53ad02341fa33b3ce489023f635367a4ac98b73570102ad2cdd770dacc9a/websockets-16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b784ca5de850f4ce93ec85d3269d24d4c82f22b7212023c974c401d4980ebc5e", size = 175268, upload-time = "2026-01-10T09:23:25.781Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/6158d4e459b984f949dcbbb0c5d270154c7618e11c01029b9bbd1bb4c4f9/websockets-16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:569d01a4e7fba956c5ae4fc988f0d4e187900f5497ce46339c996dbf24f17641", size = 175486, upload-time = "2026-01-10T09:23:27.033Z" }, + { url = "https://files.pythonhosted.org/packages/e5/2d/7583b30208b639c8090206f95073646c2c9ffd66f44df967981a64f849ad/websockets-16.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50f23cdd8343b984957e4077839841146f67a3d31ab0d00e6b824e74c5b2f6e8", size = 185331, upload-time = "2026-01-10T09:23:28.259Z" }, + { url = "https://files.pythonhosted.org/packages/45/b0/cce3784eb519b7b5ad680d14b9673a31ab8dcb7aad8b64d81709d2430aa8/websockets-16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:152284a83a00c59b759697b7f9e9cddf4e3c7861dd0d964b472b70f78f89e80e", size = 186501, upload-time = "2026-01-10T09:23:29.449Z" }, + { url = "https://files.pythonhosted.org/packages/19/60/b8ebe4c7e89fb5f6cdf080623c9d92789a53636950f7abacfc33fe2b3135/websockets-16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc59589ab64b0022385f429b94697348a6a234e8ce22544e3681b2e9331b5944", size = 186062, upload-time = "2026-01-10T09:23:31.368Z" }, + { url = "https://files.pythonhosted.org/packages/88/a8/a080593f89b0138b6cba1b28f8df5673b5506f72879322288b031337c0b8/websockets-16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206", size = 185356, upload-time = "2026-01-10T09:23:32.627Z" }, + { url = "https://files.pythonhosted.org/packages/c2/b6/b9afed2afadddaf5ebb2afa801abf4b0868f42f8539bfe4b071b5266c9fe/websockets-16.0-cp314-cp314t-win32.whl", hash = "sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6", size = 178085, upload-time = "2026-01-10T09:23:33.816Z" }, + { url = "https://files.pythonhosted.org/packages/9f/3e/28135a24e384493fa804216b79a6a6759a38cc4ff59118787b9fb693df93/websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd", size = 178531, upload-time = "2026-01-10T09:23:35.016Z" }, + { url = "https://files.pythonhosted.org/packages/72/07/c98a68571dcf256e74f1f816b8cc5eae6eb2d3d5cfa44d37f801619d9166/websockets-16.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:349f83cd6c9a415428ee1005cadb5c2c56f4389bc06a9af16103c3bc3dcc8b7d", size = 174947, upload-time = "2026-01-10T09:23:36.166Z" }, + { url = "https://files.pythonhosted.org/packages/7e/52/93e166a81e0305b33fe416338be92ae863563fe7bce446b0f687b9df5aea/websockets-16.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:4a1aba3340a8dca8db6eb5a7986157f52eb9e436b74813764241981ca4888f03", size = 175260, upload-time = "2026-01-10T09:23:37.409Z" }, + { url = "https://files.pythonhosted.org/packages/56/0c/2dbf513bafd24889d33de2ff0368190a0e69f37bcfa19009ef819fe4d507/websockets-16.0-pp311-pypy311_pp73-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f4a32d1bd841d4bcbffdcb3d2ce50c09c3909fbead375ab28d0181af89fd04da", size = 176071, upload-time = "2026-01-10T09:23:39.158Z" }, + { url = "https://files.pythonhosted.org/packages/a5/8f/aea9c71cc92bf9b6cc0f7f70df8f0b420636b6c96ef4feee1e16f80f75dd/websockets-16.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0298d07ee155e2e9fda5be8a9042200dd2e3bb0b8a38482156576f863a9d457c", size = 176968, upload-time = "2026-01-10T09:23:41.031Z" }, + { url = "https://files.pythonhosted.org/packages/9a/3f/f70e03f40ffc9a30d817eef7da1be72ee4956ba8d7255c399a01b135902a/websockets-16.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:a653aea902e0324b52f1613332ddf50b00c06fdaf7e92624fbf8c77c78fa5767", size = 178735, upload-time = "2026-01-10T09:23:42.259Z" }, + { url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" }, +] From e9487d7482ea00d470faa0e639b211bb24a00484 Mon Sep 17 00:00:00 2001 From: Richard Tibbles Date: Fri, 20 Feb 2026 18:30:15 -0800 Subject: [PATCH 3/7] Update CI workflows and build scripts for Chaquopy - Add build_and_test.yml for running unit tests in CI - Update build_apk.yml and release_apk.yml for Gradle-based builds - Remove pr_build.yml (replaced by build_and_test.yml) - Update pre-commit.yml and .pre-commit-config.yaml (add ruff, spotless) - Overhaul Makefile for Gradle/Chaquopy workflow (SDK setup, emulator, build, install, test targets) - Simplify scripts/version.py, create_strings.py, play_store_api.py for the new project structure Co-Authored-By: Claude Opus 4.6 --- .github/dependabot.yml | 2 +- .github/workflows/build_and_test.yml | 166 +++++++++++++ .github/workflows/build_apk.yml | 55 ++--- .github/workflows/pr_build.yml | 53 ---- .github/workflows/pre-commit.yml | 19 +- .github/workflows/release_apk.yml | 17 +- .pre-commit-config.yaml | 27 +- .python-version | 2 +- Makefile | 357 +++++++++++++++++---------- scripts/create_strings.py | 135 ++++------ scripts/play_store_api.py | 27 +- scripts/version.py | 114 +-------- 12 files changed, 517 insertions(+), 457 deletions(-) create mode 100644 .github/workflows/build_and_test.yml delete mode 100644 .github/workflows/pr_build.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 4c18f5af..b333eadc 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -19,7 +19,7 @@ updates: - "actions/*" # Maintain dependencies for Gradle - package-ecosystem: "gradle" - directory: "python-for-android/dists/kolibri" + directory: "/app" schedule: interval: "monthly" time: "00:00" diff --git a/.github/workflows/build_and_test.yml b/.github/workflows/build_and_test.yml new file mode 100644 index 00000000..87c8cdc2 --- /dev/null +++ b/.github/workflows/build_and_test.yml @@ -0,0 +1,166 @@ +name: Build and Test + +on: + push: + branches: + - develop + pull_request: + branches: + - develop + +jobs: + pre_job: + name: Path match check + runs-on: ubuntu-latest + outputs: + should_skip: ${{ steps.skip_check.outputs.should_skip }} + steps: + - id: skip_check + uses: fkirc/skip-duplicate-actions@v5.3.1 + with: + github_token: ${{ github.token }} + paths_ignore: '["**.po", "**.json"]' + + download_tar: + name: Download Kolibri tar + needs: pre_job + if: ${{ needs.pre_job.outputs.should_skip != 'true' }} + runs-on: ubuntu-latest + outputs: + tar-file-name: ${{ steps.get_tar.outputs.tar-file-name }} + steps: + - uses: actions/checkout@v6 + - name: Get latest Kolibri release URL + id: get_release + uses: actions/github-script@v9 + with: + result-encoding: string + script: | + + const { data: releases } = await github.rest.repos.listReleases({ + owner: 'learningequality', + repo: 'kolibri', + per_page: 1, + page: 1, + }); + + if (!releases.length) throw new Error('No releases found for kolibri'); + const latestRelease = releases[0]; + const tarAsset = latestRelease.assets.find(asset => asset.name.endsWith('.tar.gz')); + if (!tarAsset) throw new Error('No .tar.gz asset in latest release'); + return tarAsset.browser_download_url; + + - name: Download Kolibri tar + id: get_tar + env: + TAR_URL: ${{ steps.get_release.outputs.result }} + run: | + make get-tar tar="$TAR_URL" + echo "tar-file-name=$(ls tar/*.tar.gz | head -1 | xargs basename)" >> $GITHUB_OUTPUT + - name: Upload tar as artifact + uses: actions/upload-artifact@v7 + with: + name: ${{ steps.get_tar.outputs.tar-file-name }} + path: tar/${{ steps.get_tar.outputs.tar-file-name }} + + build_apk: + name: Build Debug APK + needs: [pre_job, download_tar] + if: ${{ needs.pre_job.outputs.should_skip != 'true' }} + uses: ./.github/workflows/build_apk.yml + with: + tar-file-name: ${{ needs.download_tar.outputs.tar-file-name }} + + tests: + name: Unit tests + needs: [pre_job, download_tar] + if: ${{ needs.pre_job.outputs.should_skip != 'true' }} + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + distribution: 'temurin' + java-version: '17' + cache: 'gradle' + - name: Set up uv + uses: astral-sh/setup-uv@v7 + with: + enable-cache: true + cache-python: true + - name: Install Python dependencies + run: uv sync && uv pip install -r requirements.txt + - name: Download Kolibri tar + uses: actions/download-artifact@v7 + with: + name: ${{ needs.download_tar.outputs.tar-file-name }} + path: tar + - name: Run unit tests + run: ./gradlew test + + smoke_test: + name: Smoke test (API ${{ matrix.api-level }}) + needs: [pre_job, build_apk] + if: ${{ needs.pre_job.outputs.should_skip != 'true' }} + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + api-level: [24, 30, 35] + include: + - api-level: 24 + target: default + - api-level: 30 + target: google_apis + - api-level: 35 + target: google_apis + steps: + - uses: actions/checkout@v6 + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + distribution: 'temurin' + java-version: '17' + - name: Install Maestro CLI + run: make maestro-install + - name: Add Maestro to PATH + run: echo "$HOME/.maestro/bin" >> $GITHUB_PATH + - name: Enable KVM + run: | + echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules + sudo udevadm control --reload-rules + sudo udevadm trigger --name-match=kvm + - name: Download APK artifact + uses: actions/download-artifact@v7 + with: + name: ${{ needs.build_apk.outputs.apk-file-name }} + path: dist + - name: Run smoke test + uses: reactivecircus/android-emulator-runner@v2 + with: + api-level: ${{ matrix.api-level }} + arch: x86_64 + target: ${{ matrix.target }} + force-avd-creation: false + disable-animations: true + # Hardened emulator flags. Snapshot save/restore + accelerated GPU have been + # the root cause of intermittent "bad color buffer handle" and "device offline" + # crashes mid-test on CI — `-no-snapshot` skips both load and save, and + # `swiftshader_indirect` forces software rendering. + emulator-options: -no-snapshot -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none -camera-front none + # NOTE: reactivecircus runs each line of this script as a separate `sh -c` call, + # so multi-line shell constructs (until/while/case) must live in the Makefile + # or a script file, not here. + script: | + adb install -r dist/*.apk + adb shell am set-debug-app --persistent org.learningequality.Kolibri + adb wait-for-device + until adb shell pm path org.learningequality.Kolibri >/dev/null 2>&1; do sleep 1; done + make smoke-test-with-retry + - name: Upload Maestro debug output + if: always() + uses: actions/upload-artifact@v7 + with: + name: maestro-debug-output-api-${{ matrix.api-level }} + path: ~/.maestro/tests/ diff --git a/.github/workflows/build_apk.yml b/.github/workflows/build_apk.yml index 20261692..e3af1784 100644 --- a/.github/workflows/build_apk.yml +++ b/.github/workflows/build_apk.yml @@ -102,50 +102,27 @@ jobs: # of whether this is being run from the local repository with workflow_dispatch # or from another repository with workflow_call. run: echo "sha=$(git rev-parse HEAD)" >> $GITHUB_OUTPUT - - name: Set up Python 3.9 - uses: actions/setup-python@v6 + - name: Set up JDK 17 + uses: actions/setup-java@v4 with: - python-version: 3.9 - - name: Install Apt Dependencies - # Dependencies installed here are taken from the buildozer Dockerfile: - # https://github.com/kivy/buildozer/blob/master/Dockerfile#L45 - # with items that are already installed on the Ubuntu 22.04 image removed: - # https://github.com/actions/runner-images/blob/main/images/linux/Ubuntu2204-Readme.md#installed-apt-packages - run: | - sudo apt update -qq > /dev/null \ - && DEBIAN_FRONTEND=noninteractive sudo apt install -qq --yes --no-install-recommends \ - build-essential \ - ccache \ - cmake \ - gettext \ - libffi-dev \ - libltdl-dev \ - patch \ - zlib1g-dev - - uses: actions/cache@v5 - with: - # This is where python for android puts its intermediary build - # files - we cache this to improve build performance, but be - # aggressive in clearing the cache whenever any file changes - # in the repository, especially as we commit files to this folder - # too, so we don't want the cache to override these files. - # We achieve this by just caching on the currently checked out commit. - # Every time we update this repository, this commit will change, - # but repeated workflow calls for this commit will use the cache. - path: ./python-for-android - key: ${{ runner.os }}-python-for-android-${{ steps.get-commit.outputs.sha }} - - uses: actions/cache@v5 + distribution: 'temurin' + java-version: '17' + cache: 'gradle' + - name: Set up uv + uses: astral-sh/setup-uv@v7 with: - path: ~/.cache/pip - key: ${{ runner.os }}-pip-${{ hashFiles('requirements.txt') }} - restore-keys: | - ${{ runner.os }}-pip- + enable-cache: true + cache-python: true - name: Download the tarfile from URL for workflow_dispatch if: ${{ github.event.inputs.tar-url }} - run: make get-tar tar=${{ github.event.inputs.tar-url }} + env: + TAR_URL: ${{ github.event.inputs.tar-url }} + run: make get-tar tar="$TAR_URL" - name: Download the tarfile from URL for workflow_call if: ${{ inputs.tar-url }} - run: make get-tar tar=${{ inputs.tar-url }} + env: + TAR_URL: ${{ inputs.tar-url }} + run: make get-tar tar="$TAR_URL" - name: Download the tarfile from artifacts if: ${{ inputs.tar-file-name }} uses: actions/download-artifact@v8 @@ -153,7 +130,7 @@ jobs: name: ${{ inputs.tar-file-name }} path: tar - name: Install dependencies - run: pip install -r requirements.txt + run: uv pip install --system --extra build -r requirements.txt - name: Ensure that Android SDK dependencies are installed run: make setup - name: Build the aab diff --git a/.github/workflows/pr_build.yml b/.github/workflows/pr_build.yml deleted file mode 100644 index 78f5d08e..00000000 --- a/.github/workflows/pr_build.yml +++ /dev/null @@ -1,53 +0,0 @@ -name: Build APK for PRs - -on: - pull_request: - branches: - - develop - -jobs: - pre_job: - name: Path match check - runs-on: ubuntu-latest - # Map a step output to a job output - outputs: - should_skip: ${{ steps.skip_check.outputs.should_skip }} - steps: - - id: skip_check - uses: fkirc/skip-duplicate-actions@master - with: - github_token: ${{ github.token }} - paths_ignore: '["**.po", "**.json"]' - latest_kolibri_release: - needs: pre_job - if: ${{ needs.pre_job.outputs.should_skip != 'true' }} - runs-on: ubuntu-latest - outputs: - tar-url: ${{ steps.get_latest_kolibri_release.outputs.result }} - steps: - - name: Get latest Kolibri release - id: get_latest_kolibri_release - uses: actions/github-script@v9 - with: - result-encoding: string - script: | - - const { data: releases } = await github.rest.repos.listReleases({ - owner: 'learningequality', - repo: 'kolibri', - per_page: 1, - page: 1, - }); - - const latestRelease = releases[0]; - const tarAsset = latestRelease.assets.find(asset => asset.name.endsWith('.tar.gz')); - const tarUrl = tarAsset.browser_download_url; - return tarUrl; - - build_apk: - name: Build Debug APK - needs: [pre_job, latest_kolibri_release] - if: ${{ needs.pre_job.outputs.should_skip != 'true' }} - uses: ./.github/workflows/build_apk.yml - with: - tar-url: ${{ needs.latest_kolibri_release.outputs.tar-url }} diff --git a/.github/workflows/pre-commit.yml b/.github/workflows/pre-commit.yml index d08d78e2..6fe94320 100644 --- a/.github/workflows/pre-commit.yml +++ b/.github/workflows/pre-commit.yml @@ -3,10 +3,10 @@ name: Linting on: push: branches: - - develop + - develop pull_request: branches: - - develop + - develop jobs: pre_job: @@ -17,7 +17,7 @@ jobs: should_skip: ${{ steps.skip_check.outputs.should_skip }} steps: - id: skip_check - uses: fkirc/skip-duplicate-actions@master + uses: fkirc/skip-duplicate-actions@v5.3.1 with: github_token: ${{ github.token }} paths_ignore: '["**.po", "**.json"]' @@ -27,8 +27,11 @@ jobs: if: ${{ needs.pre_job.outputs.should_skip != 'true' }} runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 - - uses: actions/setup-python@v6 - with: - python-version: 3.9 - - uses: pre-commit/action@v3.0.1 + - uses: actions/checkout@v6 + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + distribution: 'temurin' + java-version: '17' + cache: 'gradle' + - uses: j178/prek-action@v2 diff --git a/.github/workflows/release_apk.yml b/.github/workflows/release_apk.yml index 6f56425d..bb1e9ac3 100644 --- a/.github/workflows/release_apk.yml +++ b/.github/workflows/release_apk.yml @@ -35,17 +35,12 @@ jobs: with: repository: learningequality/kolibri-installer-android ref: ${{ inputs.ref }} - - name: Set up Python 3.9 - uses: actions/setup-python@v6 + - name: Set up uv + uses: astral-sh/setup-uv@v7 with: - python-version: 3.9 - - uses: actions/cache@v5 - with: - path: ~/.cache/pip - key: ${{ runner.os }}-pip-${{ hashFiles('requirements.txt') }} - restore-keys: | - ${{ runner.os }}-pip- + enable-cache: true + cache-python: true - name: Install dependencies - run: pip install -r requirements.txt + run: uv sync --extra build - name: Release APK - run: python scripts/play_store_api.py release "${{ inputs.version-code }}" + run: uv run python scripts/play_store_api.py release "${{ inputs.version-code }}" diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 0c43216d..d71ad96a 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,23 +1,24 @@ repos: - - repo: https://github.com/python/black - rev: 22.3.0 + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.9.6 hooks: - - id: black - types_or: [ python, pyi ] - - repo: https://github.com/pycqa/flake8 - rev: 4.0.1 - hooks: - - id: flake8 + - id: ruff + args: [--fix] + - id: ruff-format - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.1.0 + rev: v6.0.0 hooks: - id: trailing-whitespace - id: check-yaml + args: ['--allow-multiple-documents'] - id: check-added-large-files - id: debug-statements - id: end-of-file-fixer - - - repo: https://github.com/asottile/reorder_python_imports - rev: v2.6.0 + - repo: local hooks: - - id: reorder-python-imports + - id: spotless + name: spotless (Java/Gradle formatting) + entry: ./gradlew spotlessCheck --no-daemon + language: system + pass_filenames: false + files: \.(java|gradle)$ diff --git a/.python-version b/.python-version index 21af9507..c8cfe395 100644 --- a/.python-version +++ b/.python-version @@ -1 +1 @@ -3.9.13 +3.10 diff --git a/Makefile b/Makefile index 697ca1f5..c6c7062a 100644 --- a/Makefile +++ b/Makefile @@ -1,13 +1,8 @@ -# Run with ARCHES="arch1 arch2" to build for a smaller set of -# architectures. -ARCHES ?= \ - armeabi-v7a \ - arm64-v8a \ - x86 \ - x86_64 -export ARCHES - -ARCH_OPTIONS := $(foreach arch,$(ARCHES),--arch=$(arch)) +# Kolibri Android Makefile +# Gradle-based build system (Chaquopy) +# +# This Makefile provides convenience wrappers around Gradle commands. +# Most targets simply call ./gradlew with appropriate arguments. OSNAME := $(shell uname -s) @@ -17,55 +12,46 @@ else PLATFORM := linux endif +# Android configuration ANDROID_API := 35 ANDROIDNDKVER := 28.2.13676358 SDKMANAGER_VERSION := 13114758 +# SDK location - use project-local android_root by default ifdef ANDROID_SDK_ROOT + SDK := ${ANDROID_SDK_ROOT} else - ANDROID_SDK_ROOT := $(shell pwd)/android_root + SDK := $(shell pwd)/android_root endif -SDK := ${ANDROID_SDK_ROOT} - +# Export for Gradle export ANDROID_HOME := $(SDK) +export ANDROID_SDK_ROOT := $(SDK) export ANDROIDSDK := $(SDK) export ANDROIDNDK := $(SDK)/ndk-bundle +# Emulator configuration +AVD_NAME := kolibri-test +SYSTEM_IMAGE := system-images;android-$(ANDROID_API);default;x86_64 + ADB := adb -DOCKER := docker -P4A := p4a -PYTHON_FOR_ANDROID := python-for-android - -# This checks if an environment variable with a specific name -# exists. If it doesn't, it prints an error message and exits. -# For example to check for the presence of the ANDROIDSDK environment -# variable, you could use: -# make guard-ANDROIDSDK + +# Environment variable check helper guard-%: @ if [ "${${*}}" = "" ]; then \ echo "Environment variable $* not set"; \ exit 1; \ fi -needs-android-dirs: - $(MAKE) guard-ANDROID_SDK_ROOT - -# Clear out apks +# Clean build artifacts clean: - - rm -rf dist/*.apk src/kolibri tmpenv - - find ./src -name '*.pyc' -exec rm -f {} + - -deepclean: clean - $(PYTHON_FOR_ANDROID) clean_dists - rm -r dist || true - yes y | $(DOCKER) system prune -a || true - rm build_docker 2> /dev/null + ./gradlew clean + rm -rf dist/*.apk .PHONY: clean-tar clean-tar: - rm -rf tar - mkdir tar + rm -rf tar/extracted + mkdir -p tar .PHONY: get-tar get-tar: clean-tar @@ -75,146 +61,245 @@ get-tar: clean-tar $(eval TARFILE = $(shell echo "${DLFILE}" | sed "s/\?.*//")) [ "${DLFILE}" = "${TARFILE}" ] || mv "${DLFILE}" "${TARFILE}" -.PHONY: install-tar -# Extract the tar file -install-tar: clean - $(eval TARFILE = $(shell echo ""tar/kolibri*.tar.gz"" | sed "s/tar\///")) - echo "Installing ${TARFILE}" - rm -rf tar/patched - mkdir -p tar/patched - tar xvf "tar/${TARFILE}" --exclude="kolibri/dist/py2only*" --exclude="kolibri/dist/cext/*" --exclude="kolibri/dist/ifaddr*" --directory="tar/patched/" --strip-components=1 - # patch Django to allow migrations to be pyc files, as p4a compiles and deletes the originals - sed -i 's/if name.endswith(".py"):/if name.endswith(".py") or name.endswith(".pyc"):/g' tar/patched/kolibri/dist/django/db/migrations/loader.py - pip3 install --no-cache-dir --force-reinstall "tar/patched" - # Proactively clean up any kolibri installs from the built dist - rm -rf python-for-android/dists/kolibri/_python_bundle__*/_python_bundle/site-packages/kolibri* | true - rm -rf python-for-android/build/python-installs/kolibri/*/kolibri* | true - rm -rf python-for-android/build/other_builds/kolibri | true - -.PHONY: create-strings -create-strings: - python scripts/create_strings.py - -# Checks to see if we have any uncommitted changes in the Android project -# use this to prevent losing uncommitted changes when updating or rebuilding the P4A project -.PHONY: check-android-clean -check-android-clean: - @git diff --quiet --exit-code python-for-android || (echo "python-for-android directory has uncommitted changes in the working tree" && exit 1) - -# Create the python-for-android project bootstrap from scratch -.PHONY: p4a_android_distro -p4a_android_distro: needs-android-dirs check-android-clean - rm -rf python-for-android/dists/kolibri - $(P4A) create $(ARCH_OPTIONS) -# Stash any changes to our python-for-android directory - @git stash push --quiet --include-untracked -- python-for-android - -# Update the python-for-android project bootstrap, discarding any changes that are made to committed files -# this should be the usually run command in normal workflows. -.PHONY: p4a_android_project -p4a_android_project: install-tar p4a_android_distro create-strings - $(P4A) bootstrap $(ARCH_OPTIONS) --version="None" --numeric-version=1 -# Stash any changes to our python-for-android directory - @git stash push --quiet --include-untracked -- python-for-android - $(MAKE) write-version - -# Update the python-for-android project bootstrap, keeping any changes that are made to committed files -# this command should only be run when it is known there is an update from the upstream p4a bootstrap -# that is needed, although it will probably normally be easier to manually vendor the changes. -.PHONY: update_project_from_p4a -update_project_from_p4a: install-tar p4a_android_distro create-strings - $(P4A) bootstrap $(ARCH_OPTIONS) --version="None" --numeric-version=1 - -.version-code: - python3 scripts/version.py set_version_code - -.PHONY: write-version -write-version: .version-code - python3 scripts/version.py write_version_properties +# Build debug APK +.PHONY: kolibri.apk.unsigned +kolibri.apk.unsigned: + @echo "Building debug APK..." + ./gradlew assembleDebug + mkdir -p dist + cp app/build/outputs/apk/debug/*.apk dist/ +# Build release APK .PHONY: kolibri.apk -# Build the signed version of the apk -kolibri.apk: p4a_android_project +kolibri.apk: $(MAKE) guard-RELEASE_KEYSTORE $(MAKE) guard-RELEASE_KEYALIAS $(MAKE) guard-RELEASE_KEYSTORE_PASSWD $(MAKE) guard-RELEASE_KEYALIAS_PASSWD - @echo "--- :android: Build APK" - cd python-for-android/dists/kolibri && ./gradlew clean assembleRelease - mkdir -p dist - cp python-for-android/dists/kolibri/build/outputs/apk/release/*.apk dist/ - -.PHONY: kolibri.apk.unsigned -# Build the unsigned debug version of the apk -kolibri.apk.unsigned: p4a_android_project - @echo "--- :android: Build APK (unsigned)" - cd python-for-android/dists/kolibri && ./gradlew clean assembleDebug + @echo "Building release APK..." + ./gradlew assembleRelease mkdir -p dist - cp python-for-android/dists/kolibri/build/outputs/apk/debug/*.apk dist/ + cp app/build/outputs/apk/release/*.apk dist/ +# Build release AAB (Android App Bundle) for Play Store .PHONY: kolibri.aab -# Build the signed version of the aab -kolibri.aab: p4a_android_project +kolibri.aab: $(MAKE) guard-RELEASE_KEYSTORE $(MAKE) guard-RELEASE_KEYALIAS $(MAKE) guard-RELEASE_KEYSTORE_PASSWD $(MAKE) guard-RELEASE_KEYALIAS_PASSWD - @echo "--- :android: Build AAB" - cd python-for-android/dists/kolibri && ./gradlew clean bundleRelease + @echo "Building release AAB..." + ./gradlew bundleRelease mkdir -p dist - cp python-for-android/dists/kolibri/build/outputs/bundle/release/*.aab dist/ + cp app/build/outputs/bundle/release/*.aab dist/ +# Upload the AAB to the Play Store .PHONY: playstore-upload -# Upload the aab to the play store playstore-upload: python3 scripts/play_store_api.py upload +# Install debug APK to connected device +.PHONY: install +install: kolibri.apk.unsigned + $(ADB) install -r dist/*.apk -# DOCKER BUILD +# Uninstall from connected device +.PHONY: uninstall +uninstall: + $(ADB) uninstall org.learningequality.Kolibri || true -# Build the docker image. Should only ever need to be rebuilt if project requirements change. -# Makes dummy file -.PHONY: build_docker -build_docker: Dockerfile - $(DOCKER) build -t android_kolibri . +# Run tests +.PHONY: test +test: + ./gradlew test -# Run the docker image. -# TODO Would be better to just specify the file here? -run_docker: build_docker - env DOCKER="$(DOCKER)" ./scripts/rundocker.sh +# Run lint +.PHONY: lint +lint: + ./gradlew lint -install: - $(ADB) uninstall org.learningequality.Kolibri || true 2> /dev/null - $(ADB) install dist/*-debug-*.apk +# ============================================================================= +# SDK and Emulator Setup +# ============================================================================= -logcat: - $(ADB) logcat | grep -i -E "python|kolibr| `$(ADB) shell ps | grep ' org.learningequality.Kolibri$$' | tr -s [:space:] ' ' | cut -d' ' -f2` " | grep -E -v "WifiTrafficPoller|localhost:5000|NetworkManagementSocketTagger|No jobs to start" +# Check that ANDROID_SDK_ROOT is set (for explicit override scenarios) +needs-android-dirs: + @mkdir -p $(SDK) +# Download and install SDK command-line tools $(SDK)/cmdline-tools/latest/bin/sdkmanager: @echo "Downloading Android SDK command line tools" - wget https://dl.google.com/android/repository/commandlinetools-$(PLATFORM)-${SDKMANAGER_VERSION}_latest.zip + wget https://dl.google.com/android/repository/commandlinetools-$(PLATFORM)-$(SDKMANAGER_VERSION)_latest.zip rm -rf cmdline-tools - unzip commandlinetools-$(PLATFORM)-${SDKMANAGER_VERSION}_latest.zip -d $(SDK) + unzip commandlinetools-$(PLATFORM)-$(SDKMANAGER_VERSION)_latest.zip -d $(SDK) mv $(SDK)/cmdline-tools $(SDK)/latest mkdir -p $(SDK)/cmdline-tools mv $(SDK)/latest $(SDK)/cmdline-tools/latest - rm commandlinetools-$(PLATFORM)-${SDKMANAGER_VERSION}_latest.zip + rm commandlinetools-$(PLATFORM)-$(SDKMANAGER_VERSION)_latest.zip +# Install SDK components (platforms, build-tools, NDK, emulator) +.PHONY: sdk sdk: $(SDK)/cmdline-tools/latest/bin/sdkmanager yes y | $(SDK)/cmdline-tools/latest/bin/sdkmanager "platform-tools" yes y | $(SDK)/cmdline-tools/latest/bin/sdkmanager "platforms;android-$(ANDROID_API)" - yes y | $(SDK)/cmdline-tools/latest/bin/sdkmanager "system-images;android-$(ANDROID_API);default;x86_64" yes y | $(SDK)/cmdline-tools/latest/bin/sdkmanager "build-tools;35.0.0" yes y | $(SDK)/cmdline-tools/latest/bin/sdkmanager "ndk;$(ANDROIDNDKVER)" + yes y | $(SDK)/cmdline-tools/latest/bin/sdkmanager "emulator" ln -sfT ndk/$(ANDROIDNDKVER) $(SDK)/ndk-bundle @echo "Accepting all licenses" yes | $(SDK)/cmdline-tools/latest/bin/sdkmanager --licenses -# All of these commands are non-destructive, so if the cmdline-tools are already installed, make will skip -# based on the directory existing. -# The SDK installations will take a little time, but will not attempt to redownload if already installed. -setup: needs-android-dirs - $(MAKE) sdk +# Install system image for emulator +.PHONY: sdk-system-image +sdk-system-image: sdk + yes y | $(SDK)/cmdline-tools/latest/bin/sdkmanager "$(SYSTEM_IMAGE)" + +# Create Android Virtual Device for testing +.PHONY: avd +avd: sdk-system-image + @if $(SDK)/emulator/emulator -list-avds 2>/dev/null | grep -q "^$(AVD_NAME)$$"; then \ + echo "AVD '$(AVD_NAME)' already exists"; \ + else \ + echo "Creating AVD: $(AVD_NAME)"; \ + echo "no" | $(SDK)/cmdline-tools/latest/bin/avdmanager create avd \ + --name "$(AVD_NAME)" \ + --package "$(SYSTEM_IMAGE)" \ + --device "pixel_5"; \ + echo "AVD '$(AVD_NAME)' created"; \ + fi +# Complete setup: SDK + system image + AVD +.PHONY: setup +setup: needs-android-dirs avd + @echo "" + @echo "Setup complete! SDK location: $(SDK)" + @echo "Run 'make emulator' to start the emulator" + +# Start the emulator +.PHONY: emulator +emulator: + @if ! $(SDK)/emulator/emulator -list-avds 2>/dev/null | grep -q "^$(AVD_NAME)$$"; then \ + echo "AVD '$(AVD_NAME)' not found. Run 'make setup' first."; \ + exit 1; \ + fi + @echo "Starting emulator: $(AVD_NAME)" + $(SDK)/emulator/emulator -avd $(AVD_NAME) & + +# List available AVDs +.PHONY: list-avds +list-avds: + @$(SDK)/emulator/emulator -list-avds 2>/dev/null || echo "No AVDs found. Run 'make setup' first." + +# Clean SDK tools (removes entire android_root) +.PHONY: clean-tools clean-tools: - rm -rf ${ANDROID_SDK_ROOT} + rm -rf $(SDK) + +# ============================================================================= +# Maestro Smoke Testing +# ============================================================================= + +MAESTRO_VERSION := 2.6.0 +MAESTRO_DIR := $(HOME)/.maestro +MAESTRO := $(MAESTRO_DIR)/bin/maestro + +# Download the pinned Maestro release directly from GitHub. The official +# `curl https://get.maestro.mobile.dev | bash` installer would also work, but +# version-pinning it from a Makefile is fragile (the MAESTRO_VERSION env var +# has to reach `bash`, not `curl`), and the script's behavior can change. +$(MAESTRO): + @echo "Installing Maestro $(MAESTRO_VERSION)..." + @rm -rf $(MAESTRO_DIR)/tmp $(MAESTRO_DIR)/bin $(MAESTRO_DIR)/lib + @mkdir -p $(MAESTRO_DIR)/tmp + curl -fsSL -o $(MAESTRO_DIR)/tmp/maestro.zip \ + "https://github.com/mobile-dev-inc/maestro/releases/download/cli-$(MAESTRO_VERSION)/maestro.zip" + unzip -qo $(MAESTRO_DIR)/tmp/maestro.zip -d $(MAESTRO_DIR)/tmp + cp -rf $(MAESTRO_DIR)/tmp/maestro/. $(MAESTRO_DIR)/ + rm -rf $(MAESTRO_DIR)/tmp + +.PHONY: maestro-install +maestro-install: $(MAESTRO) + +.PHONY: smoke-test +smoke-test: maestro-install + $(MAESTRO) test .maestro/ + +# Like smoke-test, but retries once after resetting app state. Catches transient +# WebView / cold-boot races on CI emulators without papering over real bugs (cap is +# 2 attempts, not infinite). Use this in CI; use smoke-test for local iteration. +.PHONY: smoke-test-with-retry +smoke-test-with-retry: maestro-install + @attempt=0; \ + until $(MAKE) smoke-test; do \ + attempt=$$((attempt+1)); \ + if [ "$$attempt" -ge 2 ]; then \ + echo "Smoke test failed after $$attempt attempts"; \ + exit 1; \ + fi; \ + echo "Smoke test attempt $$attempt failed; resetting app state and retrying"; \ + $(ADB) shell am force-stop org.learningequality.Kolibri || true; \ + $(ADB) shell pm clear org.learningequality.Kolibri || true; \ + sleep 3; \ + done + +# ============================================================================= +# Logging +# ============================================================================= + +# View Kolibri-specific logs +.PHONY: logcat +logcat: + $(ADB) logcat | grep -i -E "python|kolibr| `$(ADB) shell ps | grep ' org.learningequality.Kolibri$$' | tr -s [:space:] ' ' | cut -d' ' -f2` " | grep -E -v "WifiTrafficPoller|localhost:5000|NetworkManagementSocketTagger|No jobs to start" + +# ============================================================================= +# Help +# ============================================================================= + +.PHONY: help +help: + @echo "Kolibri Android Build System (Chaquopy/Gradle)" + @echo "" + @echo "Quick Start:" + @echo " make setup - Set up SDK and emulator (first time)" + @echo " make emulator - Start the emulator" + @echo " make kolibri.apk.unsigned && make install - Build and install" + @echo "" + @echo "Build Targets:" + @echo " kolibri.apk.unsigned - Build debug APK → dist/" + @echo " kolibri.apk - Build release APK (requires signing keys) → dist/" + @echo " kolibri.aab - Build release AAB (requires signing keys) → dist/" + @echo " playstore-upload - Upload AAB to Play Store (requires SERVICE_ACCOUNT_JSON)" + @echo "" + @echo "Development Targets:" + @echo " install - Install debug APK to connected device/emulator" + @echo " uninstall - Uninstall app from device" + @echo " logcat - View Kolibri-specific logs" + @echo " test - Run unit tests" + @echo " lint - Run Android linter" + @echo " clean - Clean build artifacts" + @echo " maestro-install - Install Maestro CLI" + @echo " smoke-test - Run Maestro smoke tests (requires running emulator + installed APK)" + @echo "" + @echo "SDK & Emulator Setup:" + @echo " setup - Complete setup (SDK + system image + AVD)" + @echo " sdk - Install SDK components only" + @echo " sdk-system-image - Install emulator system image" + @echo " avd - Create Android Virtual Device" + @echo " emulator - Start the emulator" + @echo " list-avds - List available AVDs" + @echo " clean-tools - Remove SDK (android_root/)" + @echo "" + @echo "Kolibri Source:" + @echo " get-tar - Download Kolibri tar (use: make get-tar tar=URL)" + @echo " clean-tar - Remove extracted Kolibri directory" + @echo "" + @echo "Environment Variables:" + @echo " ANDROID_SDK_ROOT - Android SDK location (default: ./android_root)" + @echo " (current: $(SDK))" + @echo " AVD_NAME - Emulator name (default: kolibri-test)" + @echo "" + @echo "Release Build Variables (required for 'make kolibri.apk'):" + @echo " RELEASE_KEYSTORE - Path to release keystore (.jks file)" + @echo " RELEASE_KEYALIAS - Release key alias" + @echo " RELEASE_KEYSTORE_PASSWD - Keystore password" + @echo " RELEASE_KEYALIAS_PASSWD - Key password" diff --git a/scripts/create_strings.py b/scripts/create_strings.py index 93f32726..64f9fc0f 100644 --- a/scripts/create_strings.py +++ b/scripts/create_strings.py @@ -1,13 +1,17 @@ """ Tooling for generating i18n strings using Kolibri's translation machinery. """ + import json import os -import tempfile +import sys import xml.etree.ElementTree as ET from importlib import resources -from version import apk_version +# Add extracted Kolibri tar to Python path +EXTRACTED_TAR_PATH = os.path.join(os.path.dirname(__file__), "../tar/extracted") +if os.path.exists(EXTRACTED_TAR_PATH): + sys.path.insert(0, EXTRACTED_TAR_PATH) # By default we will map the locale code to e.g. "en-us" to "en-rUS" @@ -18,43 +22,15 @@ "zh-hans": "zh", } -XML_TEMPLATE = """ - - - -""" - # The language code that will be used for the non-prefixed values folder DEFAULT_LANGUAGE = "en" -def generate_loading_pages(output_dir): - """ - Run the Django management command to generate the loading pages. - """ - # Add the local Kolibri source directory to the path - - from kolibri.main import initialize - from django.core.management import call_command - - initialize(skip_update=True) - - call_command( - "loadingpage", - "--output-dir", - output_dir, - "--version-text", - apk_version().replace("-official", ""), - ) - - def _find_string(lang, string): - from kolibri.main import initialize - from django.utils.translation import override - from django.utils.translation import gettext as _ - from django.utils.translation import to_locale + from kolibri.main import initialize # isort: skip # noqa: E402 + from django.utils.translation import gettext as _ # noqa: E402 + from django.utils.translation import override # noqa: E402 + from django.utils.translation import to_locale # noqa: E402 initialize(skip_update=True) @@ -102,61 +78,50 @@ def _find_string(lang, string): # Strings that we only access from Python, so we don't need to include them in the strings.xml file -PYTHON_ONLY_STRINGS = [ - "Learner", -] +PYTHON_ONLY_STRINGS = [] -def create_resource_files(output_dir): # noqa: C901 +def create_resource_files(): # noqa: C901 """ Read each language directory and create resource files in the corresponding Android values folder. """ en_strings_file = os.path.join( os.path.dirname(__file__), - "../python-for-android/dists/kolibri/src/main/res/values/strings.xml", + "../app/src/main/res/values/strings.xml", ) en_strings_tree = ET.parse(en_strings_file) en_strings_root = en_strings_tree.getroot() - all_langs = list(os.listdir(output_dir)) + from kolibri.utils.i18n import KOLIBRI_SUPPORTED_LANGUAGES # noqa: E402 + + all_langs = sorted(KOLIBRI_SUPPORTED_LANGUAGES) for lang_dir in all_langs: if lang_dir == DEFAULT_LANGUAGE: - dir_name = "values" + continue + + if lang_dir in locale_code_map: + locale_dir = locale_code_map[lang_dir] else: - if lang_dir in locale_code_map: - locale_dir = locale_code_map[lang_dir] + parts = lang_dir.split("-") + if len(parts) == 1: + locale_dir = lang_dir + elif len(parts) == 2: + locale_dir = f"{parts[0]}-r{parts[1].upper()}" else: - parts = lang_dir.split("-") - if len(parts) == 1: - locale_dir = lang_dir - elif len(parts) == 2: - locale_dir = f"{parts[0]}-r{parts[1].upper()}" - else: - raise ValueError(f"Invalid language code: {lang_dir}") - dir_name = f"values-{locale_dir}" + raise ValueError(f"Invalid language code: {lang_dir}") + dir_name = f"values-{locale_dir}" values_dir = os.path.join( os.path.dirname(__file__), - "../python-for-android/dists/kolibri/src/main/res", + "../app/src/main/res", dir_name, ) os.makedirs(values_dir, exist_ok=True) - with open(os.path.join(output_dir, lang_dir, "loading.html"), "r") as f: - html_content = f.read().replace("'", "\\'").replace('"', '\\"') - - xml_content = XML_TEMPLATE.format(html_content) - - with open(os.path.join(values_dir, "html_content.xml"), "w") as f: - f.write(xml_content) - - if lang_dir == DEFAULT_LANGUAGE: - continue - new_root = ET.Element("resources") new_tree = ET.ElementTree(element=new_root) @@ -165,7 +130,9 @@ def create_resource_files(output_dir): # noqa: C901 value = _find_string(lang_dir, string.text) if value is None: continue - new_string = ET.SubElement(new_root, "string", attrib={"name": name}) + new_string = ET.SubElement( + new_root, "string", attrib={"name": name} + ) new_string.text = value new_tree.write( @@ -174,29 +141,33 @@ def create_resource_files(output_dir): # noqa: C901 xml_declaration=True, ) - # Create the Python strings file - output = "# This file is auto-generated by the create_strings.py script. Do not edit it directly." - output += "\ni18n_strings = {" - for python_string in PYTHON_ONLY_STRINGS: - output += "\n " + f"'{python_string}': " + "{" - for lang_dir in all_langs: - value = _find_string(lang_dir, python_string) - if value is None: - continue - output += f"\n '{lang_dir}': '{value}', " - output += "\n }," - output += "\n}\n" - with open(os.path.join(os.path.dirname(__file__), "../src/strings.py"), "w") as f: - f.write(output) + # Create the Python strings file (only if there are Python-only strings) + if PYTHON_ONLY_STRINGS: + output = "# This file is auto-generated by the create_strings.py script. Do not edit it directly." + output += "\ni18n_strings = {" + for python_string in PYTHON_ONLY_STRINGS: + output += "\n " + f"'{python_string}': " + "{" + for lang_dir in all_langs: + value = _find_string(lang_dir, python_string) + if value is None: + continue + output += f"\n '{lang_dir}': '{value}', " + output += "\n }," + output += "\n}\n" + with open( + os.path.join( + os.path.dirname(__file__), "../app/src/main/python/strings.py" + ), + "w", + ) as f: + f.write(output) def main(): """ - Run the script to generate the loading pages and create the Android resource files. + Run the script to generate the Android resource files with translated strings. """ - with tempfile.TemporaryDirectory() as temp_dir: - generate_loading_pages(temp_dir) - create_resource_files(temp_dir) + create_resource_files() if __name__ == "__main__": diff --git a/scripts/play_store_api.py b/scripts/play_store_api.py index 89042e89..4414e41f 100644 --- a/scripts/play_store_api.py +++ b/scripts/play_store_api.py @@ -11,7 +11,6 @@ from googleapiclient.errors import HttpError from googleapiclient.http import MediaIoBaseDownload - # Register mimetypes for aab and apk files mimetypes.add_type("application/octet-stream", ".apk") mimetypes.add_type("application/octet-stream", ".aab") @@ -47,7 +46,11 @@ def _get_service(): def _create_edit(service): - return service.edits().insert(body={}, packageName=package_name()).execute()["id"] + return ( + service.edits() + .insert(body={}, packageName=package_name()) + .execute()["id"] + ) def get_latest_version_code(): @@ -132,7 +135,9 @@ def upload_dist_aab(): # Commit changes for edit. commit_request = ( - service.edits().commit(editId=edit_id, packageName=package_name()).execute() + service.edits() + .commit(editId=edit_id, packageName=package_name()) + .execute() ) print("Edit id {} has been committed".format(commit_request["id"])) @@ -161,7 +166,9 @@ def upload_dist_aab(): time.sleep(15) continue - print("Universal APK generated with download ID: {}".format(universal_apk_id)) + print( + "Universal APK generated with download ID: {}".format(universal_apk_id) + ) downloaded_attempts = 0 @@ -176,7 +183,9 @@ def upload_dist_aab(): filename = "kolibri-{}-release-universal.apk".format(apk_version()) - filepath = os.path.join(os.path.dirname(__file__), "../dist", filename) + filepath = os.path.join( + os.path.dirname(__file__), "../dist", filename + ) with open(filepath, "wb") as f: downloader = MediaIoBaseDownload( @@ -230,11 +239,15 @@ def release_app(version_code): # Commit changes for edit. commit_response = ( - service.edits().commit(editId=edit_id, packageName=package_name()).execute() + service.edits() + .commit(editId=edit_id, packageName=package_name()) + .execute() ) print("Edit id {} has been committed".format(commit_response["id"])) - print("App version {} has been promoted to open testing.".format(version_code)) + print( + "App version {} has been promoted to open testing.".format(version_code) + ) if __name__ == "__main__": diff --git a/scripts/version.py b/scripts/version.py index 32b42818..d508e48c 100644 --- a/scripts/version.py +++ b/scripts/version.py @@ -1,110 +1,12 @@ -import os -import sys -from datetime import datetime +""" +Utility script for production builds to query Play Store API for version codes. +For dev builds, version is calculated in build.gradle. -from play_store_api import get_latest_version_code - - -android_installer_version = "0.1.10" - - -BUILD_TYPE_DEBUG = "debug" -BUILD_TYPE_DEV = "dev" -BUILD_TYPE_OFFICIAL = "official" - - -VERSION_CODE_FILE = os.path.join(os.path.dirname(__file__), "../.version-code") - - -def kolibri_version(): - """ - Returns the major.minor version of Kolibri if it exists - """ - import kolibri - - return kolibri.__version__ - - -def build_type(): - key_alias = os.getenv("RELEASE_KEYALIAS", "") - if key_alias == "LE_DEV_KEY": - return BUILD_TYPE_DEV - if key_alias == "LE_RELEASE_KEY": - return BUILD_TYPE_OFFICIAL - return BUILD_TYPE_DEBUG - - -def apk_version(): - """ - Returns the version to be used for the Kolibri Android app. - Schema: [kolibri version]-[android installer version or githash]-[build signature type] - """ - return "{}-{}-{}".format(kolibri_version(), android_installer_version, build_type()) - - -def _generate_build_number(): - """ - Generates the build number - this should not be called more than once per build. - """ - if build_type() == BUILD_TYPE_OFFICIAL: - return get_latest_version_code() + 1 - # build_base_number is no longer strictly needed, but keeping here to remind us - # why our versionCodes are so high - also, we use this base build number to - # make sure the time based build number is not higher than the maximum value - # of 2100000000, so it still serves a purpose! - # It also has the additional advantage of ensuring that the build number - # for the dev build is always lower than the build number for the official build. - # Meaning we shouldn't accidentally release a development build. - # Patch, due to a build error. - # Envar was not being passed into the container this runs in, and the - # build submitted to the play store ended up using the dev build number. - # We can't go backwards. So we're adding to the one submitted at first. - build_base_number = 2008998000 - return int(datetime.now().strftime("%y%m%d%H%M")) - build_base_number - - -def write_build_number(): - """ - Writes the build number to a file. - """ - with open(VERSION_CODE_FILE, "w") as f: - f.write(str(_generate_build_number())) - - -def build_number(): - """ - Returns the build number for the apk. See the functions above for how the file this is - read from is generated. - """ - try: - with open(VERSION_CODE_FILE, "r") as f: - return int(f.read()) - except Exception as e: - print("Improper version code file, have you generated a version code?") - raise e - - -def fileoutput(): - """ - Writes the version to a version.properties file - in the kolibri android project. - """ - with open( - os.path.join( - os.path.dirname(__file__), - "../python-for-android/dists/kolibri", - "version.properties", - ), - "w", - ) as f: - f.write("VERSION_NAME:{}\n".format(apk_version())) - f.write("VERSION_CODE:{}\n".format(build_number())) +Outputs the next version code to stdout for Gradle to read. +""" +from play_store_api import get_latest_version_code if __name__ == "__main__": - if sys.argv[1] == "set_version_code": - write_build_number() - elif sys.argv[1] == "write_version_properties": - fileoutput() - else: - raise RuntimeError("Unknown command {}".format(sys.argv[1])) + version_code = get_latest_version_code() + 1 + print(version_code) From ff0500e9a0f4f1e3ec32d55da71b778ffe3fbd55 Mon Sep 17 00:00:00 2001 From: Richard Tibbles Date: Fri, 20 Feb 2026 18:30:24 -0800 Subject: [PATCH 4/7] Add unit tests for utility classes and task workers - AuthUtilsTest: token generation, URL building, header extraction - ContextUtilTest: singleton initialization and context access - ShareUtilsTest: share intent construction - BaseTaskWorkerTest: Python task delegation and error handling Co-Authored-By: Claude Opus 4.6 --- .../Kolibri/KolibriEnvironmentSetupTest.java | 106 ++++++++++++++++++ .../Kolibri/util/AuthUtilsTest.java | 88 +++++++++++++++ .../Kolibri/util/ContextUtilTest.java | 53 +++++++++ .../Kolibri/util/ShareUtilsTest.java | 41 +++++++ .../Kolibri/workers/BaseTaskWorkerTest.java | 71 ++++++++++++ 5 files changed, 359 insertions(+) create mode 100644 app/src/test/java/org/learningequality/Kolibri/KolibriEnvironmentSetupTest.java create mode 100644 app/src/test/java/org/learningequality/Kolibri/util/AuthUtilsTest.java create mode 100644 app/src/test/java/org/learningequality/Kolibri/util/ContextUtilTest.java create mode 100644 app/src/test/java/org/learningequality/Kolibri/util/ShareUtilsTest.java create mode 100644 app/src/test/java/org/learningequality/Kolibri/workers/BaseTaskWorkerTest.java diff --git a/app/src/test/java/org/learningequality/Kolibri/KolibriEnvironmentSetupTest.java b/app/src/test/java/org/learningequality/Kolibri/KolibriEnvironmentSetupTest.java new file mode 100644 index 00000000..7747fa9b --- /dev/null +++ b/app/src/test/java/org/learningequality/Kolibri/KolibriEnvironmentSetupTest.java @@ -0,0 +1,106 @@ +package org.learningequality.Kolibri; + +import static org.junit.Assert.assertTrue; + +import java.lang.reflect.Field; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; + +public class KolibriEnvironmentSetupTest { + + private Field initFutureField; + private Field initializedField; + + @Before + public void resetState() throws Exception { + initFutureField = KolibriEnvironmentSetup.class.getDeclaredField("initFuture"); + initFutureField.setAccessible(true); + initializedField = KolibriEnvironmentSetup.class.getDeclaredField("initialized"); + initializedField.setAccessible(true); + initFutureField.set(null, null); + initializedField.setBoolean(null, false); + } + + @After + public void teardown() throws Exception { + initFutureField.set(null, null); + initializedField.setBoolean(null, false); + } + + /** + * Regression test: initializeEnvAsync MUST NOT block on the class monitor. The synchronous + * initializeEnv method is {@code synchronized} on the class, and a background ForkJoinPool worker + * holds that monitor for the entire init (multiple seconds). If initializeEnvAsync shares that + * monitor, any main-thread caller blocks for the full init duration — producing the ANR seen in + * the field on Samsung Note10 (Android 13) and Motorola Moto g05 (Android 15). + */ + @Test + public void initializeEnvAsync_doesNotBlockOnClassMonitor() throws Exception { + CountDownLatch monitorHeld = new CountDownLatch(1); + CountDownLatch releaseMonitor = new CountDownLatch(1); + + Thread blocker = + new Thread( + () -> { + synchronized (KolibriEnvironmentSetup.class) { + monitorHeld.countDown(); + try { + releaseMonitor.await(5, TimeUnit.SECONDS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + }, + "monitor-holder"); + blocker.start(); + assertTrue( + "blocker thread failed to acquire class monitor", monitorHeld.await(1, TimeUnit.SECONDS)); + + try { + long start = System.nanoTime(); + try { + // Passing null context: the scheduled future will fail when it actually runs on the + // ForkJoinPool worker, but that happens asynchronously. The call itself must return. + KolibriEnvironmentSetup.initializeEnvAsync(null); + } catch (Throwable ignored) { + // Tolerate any synchronous failure — the bug under test is blocking, not throwing. + } + long elapsedMs = (System.nanoTime() - start) / 1_000_000; + + assertTrue( + "initializeEnvAsync blocked for " + + elapsedMs + + "ms while the class monitor was held; " + + "must use a separate lock", + elapsedMs < 500); + } finally { + releaseMonitor.countDown(); + blocker.join(2000); + // Cancel any future we scheduled so the ForkJoinPool worker doesn't actually run init. + CompletableFuture future = (CompletableFuture) initFutureField.get(null); + if (future != null) { + future.cancel(true); + } + } + } + + /** + * Verifies idempotence: a second initializeEnvAsync call is a no-op once init has been scheduled. + * This is what the KolibriServerService.onCreate call relies on. + */ + @Test + public void initializeEnvAsync_idempotent_secondCallIsNoOp() throws Exception { + CompletableFuture sentinel = new CompletableFuture<>(); + initFutureField.set(null, sentinel); + + KolibriEnvironmentSetup.initializeEnvAsync(null); + + assertTrue( + "Second initializeEnvAsync call must not replace the existing future", + initFutureField.get(null) == sentinel); + } +} diff --git a/app/src/test/java/org/learningequality/Kolibri/util/AuthUtilsTest.java b/app/src/test/java/org/learningequality/Kolibri/util/AuthUtilsTest.java new file mode 100644 index 00000000..4ddef8e1 --- /dev/null +++ b/app/src/test/java/org/learningequality/Kolibri/util/AuthUtilsTest.java @@ -0,0 +1,88 @@ +package org.learningequality.Kolibri.util; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +import android.content.Context; +import android.content.SharedPreferences; +import java.io.File; +import java.io.FileWriter; +import java.io.IOException; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.RuntimeEnvironment; + +@RunWith(RobolectricTestRunner.class) +public class AuthUtilsTest { + + private Context context; + + @Before + public void setUp() { + context = RuntimeEnvironment.getApplication(); + ContextUtil.init(context); + // Clear prefs before each test + context.getSharedPreferences("kolibri_auth", Context.MODE_PRIVATE).edit().clear().commit(); + } + + @Test + public void migrateLegacyToken_readsTokenFromFile_andPersistsToPrefs() throws IOException { + // Set up legacy file + File externalFilesDir = context.getExternalFilesDir(null); + File cacheDir = new File(externalFilesDir, ".value_cache"); + cacheDir.mkdirs(); + File legacyFile = new File(cacheDir, "OS_USER_AUTH_TOKEN"); + + String expectedToken = "abcdef1234567890abcdef1234567890"; + FileWriter writer = new FileWriter(legacyFile); + writer.write(expectedToken); + writer.close(); + + // Call getOrCreateAuthToken which should migrate + String token = AuthUtils.getOrCreateAuthToken(); + + assertEquals(expectedToken, token); + + // Verify it was persisted to SharedPreferences + SharedPreferences prefs = context.getSharedPreferences("kolibri_auth", Context.MODE_PRIVATE); + assertEquals(expectedToken, prefs.getString("os_user_auth_token", null)); + } + + @Test + public void migrateLegacyToken_deletesLegacyFile_afterPersisting() throws IOException { + // Set up legacy file + File externalFilesDir = context.getExternalFilesDir(null); + File cacheDir = new File(externalFilesDir, ".value_cache"); + cacheDir.mkdirs(); + File legacyFile = new File(cacheDir, "OS_USER_AUTH_TOKEN"); + + FileWriter writer = new FileWriter(legacyFile); + writer.write("abcdef1234567890abcdef1234567890"); + writer.close(); + + assertTrue("Legacy file should exist before migration", legacyFile.exists()); + + AuthUtils.getOrCreateAuthToken(); + + assertFalse("Legacy file should be deleted after migration", legacyFile.exists()); + } + + @Test + public void getOrCreateAuthToken_generatesNewToken_whenNoLegacyExists() { + String token = AuthUtils.getOrCreateAuthToken(); + assertNotNull(token); + assertEquals("Token should be 32 hex chars", 32, token.length()); + assertTrue("Token should be hex", token.matches("[0-9a-f]+")); + } + + @Test + public void getOrCreateAuthToken_returnsSameToken_onSubsequentCalls() { + String token1 = AuthUtils.getOrCreateAuthToken(); + String token2 = AuthUtils.getOrCreateAuthToken(); + assertEquals("Subsequent calls should return same token", token1, token2); + } +} diff --git a/app/src/test/java/org/learningequality/Kolibri/util/ContextUtilTest.java b/app/src/test/java/org/learningequality/Kolibri/util/ContextUtilTest.java new file mode 100644 index 00000000..7144d306 --- /dev/null +++ b/app/src/test/java/org/learningequality/Kolibri/util/ContextUtilTest.java @@ -0,0 +1,53 @@ +package org.learningequality.Kolibri.util; + +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertTrue; + +import android.content.Context; +import java.io.File; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.RuntimeEnvironment; + +@RunWith(RobolectricTestRunner.class) +public class ContextUtilTest { + + @Before + public void setUp() { + ContextUtil.init(RuntimeEnvironment.getApplication()); + } + + @Test + public void getExternalFilesDir_returnsPath_whenExternalStorageAvailable() { + String path = ContextUtil.getExternalFilesDir(); + assertNotNull(path); + assertTrue(path.length() > 0); + } + + @Test + public void getExternalFilesDir_fallsBackToInternal_whenExternalStorageNull() { + // Create a context wrapper that returns null for getExternalFilesDir + // AND returns itself as getApplicationContext so ContextUtil stores it. + Context nullExternalContext = + new android.content.ContextWrapper(RuntimeEnvironment.getApplication()) { + @Override + public File getExternalFilesDir(String type) { + return null; + } + + @Override + public Context getApplicationContext() { + return this; + } + }; + ContextUtil.init(nullExternalContext); + // Before fix: this throws NPE because getExternalFilesDir(null) returns null + // and we call .getAbsolutePath() on null. + // After fix: should fall back to getFilesDir() and return a valid path. + String path = ContextUtil.getExternalFilesDir(); + assertNotNull("Should fall back to internal storage when external is null", path); + assertTrue(path.length() > 0); + } +} diff --git a/app/src/test/java/org/learningequality/Kolibri/util/ShareUtilsTest.java b/app/src/test/java/org/learningequality/Kolibri/util/ShareUtilsTest.java new file mode 100644 index 00000000..90dbedda --- /dev/null +++ b/app/src/test/java/org/learningequality/Kolibri/util/ShareUtilsTest.java @@ -0,0 +1,41 @@ +package org.learningequality.Kolibri.util; + +import android.content.ActivityNotFoundException; +import android.content.Context; +import android.content.Intent; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.RuntimeEnvironment; + +@RunWith(RobolectricTestRunner.class) +public class ShareUtilsTest { + + @Before + public void setUp() { + // Create a context wrapper that throws ActivityNotFoundException on startActivity + // AND returns itself as getApplicationContext so ContextUtil stores it. + Context throwingContext = + new android.content.ContextWrapper(RuntimeEnvironment.getApplication()) { + @Override + public void startActivity(Intent intent) { + throw new ActivityNotFoundException("No activity found"); + } + + @Override + public Context getApplicationContext() { + return this; + } + }; + ContextUtil.init(throwingContext); + } + + @Test + public void shareByIntent_handlesActivityNotFound_gracefully() { + // Before fix: this throws ActivityNotFoundException and crashes + // After fix: exception is caught and logged + ShareUtils.shareByIntent(null, "test message", null, null); + // No exception = pass + } +} diff --git a/app/src/test/java/org/learningequality/Kolibri/workers/BaseTaskWorkerTest.java b/app/src/test/java/org/learningequality/Kolibri/workers/BaseTaskWorkerTest.java new file mode 100644 index 00000000..c0552377 --- /dev/null +++ b/app/src/test/java/org/learningequality/Kolibri/workers/BaseTaskWorkerTest.java @@ -0,0 +1,71 @@ +package org.learningequality.Kolibri.workers; + +import static org.junit.Assert.assertEquals; + +import androidx.work.Data; +import org.junit.Test; + +/** + * Tests for BaseTaskWorker's progress deduplication logic. + * + *

    The hashCode-based dedup can cause collisions where two different Data objects with the same + * hashCode are incorrectly treated as duplicates. Using equals() fixes this. + */ +public class BaseTaskWorkerTest { + + /** + * Verifies that two Data objects that are logically equal (same key/value pairs) report equals() + * == true. This is the baseline case where dedup should suppress the update. + */ + @Test + public void data_equals_returnsTrueForIdenticalData() { + Data data1 = + new Data.Builder() + .putString("id", "abc") + .putString("notificationTitle", "Downloading") + .putString("notificationText", "50%") + .putInt("progress", 50) + .putInt("totalProgress", 100) + .build(); + + Data data2 = + new Data.Builder() + .putString("id", "abc") + .putString("notificationTitle", "Downloading") + .putString("notificationText", "50%") + .putInt("progress", 50) + .putInt("totalProgress", 100) + .build(); + + assertEquals("Identical Data objects should be equal", data1, data2); + } + + /** + * Verifies that two Data objects with different values are not equal, even if they might produce + * the same hashCode. This test documents the risk: hashCode collisions cause false dedup. + * + *

    Note: We can't easily construct Data objects with guaranteed hashCode collisions in a unit + * test, but we CAN verify that equals() correctly distinguishes objects with different content. + */ + @Test + public void data_equals_returnsFalseForDifferentData() { + Data data1 = + new Data.Builder() + .putString("id", "abc") + .putString("notificationTitle", "Downloading") + .putInt("progress", 50) + .putInt("totalProgress", 100) + .build(); + + Data data2 = + new Data.Builder() + .putString("id", "abc") + .putString("notificationTitle", "Downloading") + .putInt("progress", 51) + .putInt("totalProgress", 100) + .build(); + + // equals() correctly distinguishes these; hashCode() might not + assertEquals(false, data1.equals(data2)); + } +} From cbbdbf144da48d4cc4a2423dbe023caf6aeb11e5 Mon Sep 17 00:00:00 2001 From: Richard Tibbles Date: Fri, 20 Feb 2026 18:30:29 -0800 Subject: [PATCH 5/7] Update README for Chaquopy-based architecture Rewrite documentation to reflect the new build system, project structure, and development workflow. Covers Gradle setup, Chaquopy Python integration, emulator usage, and CI configuration. Co-Authored-By: Claude Opus 4.6 --- README.md | 269 +++++++++++++++++++++++++++++++++++++++--------------- 1 file changed, 197 insertions(+), 72 deletions(-) diff --git a/README.md b/README.md index 422ad770..ecfe1729 100644 --- a/README.md +++ b/README.md @@ -1,119 +1,244 @@ -# Kolibri Android Installer +# Kolibri Android -Wraps Kolibri in an android-compatibility layer. Relies on Python-For-Android to build the APK and for compatibility on the Android platform. +Android application for Kolibri Learning Platform using Chaquopy for Python integration. -## Development Flow +## Overview -1. Setup a Python virtual environment in which to do development. The Kolibri developer documentation has a [How To guide for doing this with pyenv](https://kolibri-dev.readthedocs.io/en/develop/howtos/pyenv_virtualenv.html) but any Python virtualenv should work. +This project packages [Kolibri](https://learningequality.org/kolibri/) as an Android application using: +- **Chaquopy** - Runs Python code directly on Android +- **HTTP Server + Service Worker** - Python runs an HTTP server, WebView loads content, Service Worker handles caching +- **WorkManager** - Handles background tasks in a separate process +- **Modern Android Architecture** - Clean package structure, lifecycle-aware components -2. Ensure you have all [necessary packages for Python for Android](https://python-for-android.readthedocs.io/en/latest/quickstart.html#installing-prerequisites). Ensure you install java version 1.17, `sudo apt install openjdk-17-jdk` , and set it as the default java version: `sudo update-alternatives --auto javac` and `sudo update-alternatives --auto java`. +## Quick Start -3. The `make setup` command will install the Android SDK and Android NDK. +```bash +# 1. Ensure Java 17+ is installed +java -version -N.B. if you would like these to be installed to a different location then you can set an environment variable, e.g.: -By default it is set to `export ANDROID_SDK_ROOT=./android_root` +# 2. Setup Python environment (recommended) +uv sync --extra build +uv pip install -r requirements.txt +source .venv/bin/activate -Run `make setup`. +# 3. Setup Android SDK and emulator +make setup -4. Install the Python dependencies: +# 4. Download Kolibri tar file +make get-tar tar=https://github.com/learningequality/kolibri/releases/download/v0.17.0/kolibri-0.17.0.tar.gz -`pip install -r requirements.txt` +# 5. Build! +make kolibri.apk.unsigned +``` + +Output: `dist/kolibri-*.apk` + +## Development Setup + +### Prerequisites + +- **Java Development Kit (JDK) 17+** + - Fedora/RHEL: `sudo dnf install java-17-openjdk-devel` + - Ubuntu/Debian: `sudo apt install openjdk-17-jdk` + - macOS: `brew install openjdk@17` -5. Build or download a Kolibri tar file, and place it in the `tar/` directory. +- **Python 3.10+** - For build scripts and Chaquopy -To download a Kolibri WHL file, you can use `make get-tar tar=` from the command line. It will download it and put it in the correct directory. +### Initial Setup -6. By default the APK/AAB will be built for most architectures supported by Python for Android. To build for a smaller set of architectures, set the `ARCHES` environment variable. Run `p4a archs` to see the available targets. +1. **Clone the repository** + ```bash + git clone https://github.com/learningequality/kolibri-installer-android.git + cd kolibri-installer-android + ``` -7. Run `make p4a_android_project` this will do all of the Python for Android setup up. After, you can run `make kolibri.apk` or `make kolibri.apk.unsigned` if you want to build the apk in the console. +2. **Set up Python virtual environment** + ```bash + uv venv + source .venv/bin/activate + uv pip install ".[build]" -r requirements.txt + ``` -N.B. You will need to rerun this step any time you update the Kolibri WHL file you are using, or any time you update the Python code in this repository. +3. **Set up Android SDK** (automatically downloads SDK, NDK, emulator) + ```bash + make setup + ``` -8. You can now run Android Studio and open the folder `python-for-android/dists/kolibri` as the project folder to work from. You should be able to make updates to Java code, resource files, etc. using Android Studio, and build and run the project using Android Studio, including launching into emulators and real physical devices. +4. **Get Kolibri tar file** + ```bash + make get-tar tar= + ``` -N.B. When you rerun step 7, it will complain loudly and exit early if you have uncommitted changes in the python-for-android folder. Any changes should be committed (even if in a temporary commit) before rerunning this step, as we use git stash to undo any changes in the Android project caused by the Python for Android project bootstrapping process. Also, when rerunning step 5, the Android version will not have incremented, meaning that any emulator or physical device will need to have Kolibri explicitly uninstalled for any changes to Python code to be updated on install. +5. **Build the APK** + ```bash + make kolibri.apk.unsigned + ``` -## Debugging the app +## Architecture -1. When running the app from Android Studio, if you are using an emulator, it is possible that there will be many warning messages due to GPU emulation. In the logcat tab, update the filter to this `package:mine & -tag:eglCodecCommon` to hide those errors from the logcat output. +The app runs Kolibri as an HTTP server in Python, with a WebView displaying the UI: -## Building from the commandline +``` +┌─────────────────────────────────────────────────────────────┐ +│ Main Process │ +│ ┌────────────────┐ ┌──────────────────┐ │ +│ │ WebViewActivity│ │ KolibriServer │ │ +│ │ │ │ Service │ │ +│ │ ┌──────────┐ │ │ ┌────────────┐ │ │ +│ │ │ WebView │──┼─ HTTP ──┼─▶│ Python │ │ │ +│ │ │ │ │ │ │ HTTP │ │ │ +│ │ │ Service │ │ │ │ Server │ │ │ +│ │ │ Worker │ │ │ │ (Kolibri) │ │ │ +│ │ └──────────┘ │ │ └────────────┘ │ │ +│ └────────────────┘ └──────────────────┘ │ +└─────────────────────────────────────────────────────────────┘ + +┌─────────────────────────────────────────────────────────────┐ +│ :task_worker Process │ +│ ┌────────────────┐ ┌──────────────────┐ │ +│ │ WorkController │────────▶│ WorkManager │ │ +│ │ Service │ │ Workers │ │ +│ └────────────────┘ └──────────────────┘ │ +└─────────────────────────────────────────────────────────────┘ +``` + +### Key Components -1. Run `make kolibri.apk.unsigned` to build the development apk. Watch for success at the end, or errors, which might indicate missing build dependencies or build errors. If successful, there should be an APK in the `dist/` directory. +- **KolibriServerService** - Starts Python HTTP server in background +- **WebViewActivity** - Displays Kolibri UI, requests notification permission +- **WorkManager** - Runs background tasks (imports, syncs) in separate process +- **Task Reconciler** - Syncs WorkManager state with Kolibri's job database -## Installing the apk -1. Connect your Android device over USB, with USB Debugging enabled. +## Project Structure -2. Ensure that `adb devices` brings up your device. Afterward, run `make install` to install onto the device. +``` +app/src/main/ +├── java/org/learningequality/Kolibri/ +│ ├── App.java # Application entry point +│ ├── WebViewActivity.java # Main activity with WebView +│ ├── KolibriServerService.java # HTTP server service +│ ├── KolibriServerViewModel.java # Server state management +│ ├── KolibriEnvironmentSetup.java # Environment configuration +│ ├── WorkController.java # Task scheduling +│ ├── WorkControllerService.java # Worker process IPC +│ ├── notification/ # Notification system +│ ├── task/ # Task worker interfaces +│ │ ├── Task.java # WorkManager wrapper +│ │ └── TaskWorkerImpl.java # Observer pattern for progress +│ ├── workers/ # WorkManager workers +│ │ ├── BaseTaskWorker.java # Base class with notifications +│ │ ├── ForegroundWorker.java # Long-running tasks +│ │ └── BackgroundWorker.java # Short tasks +│ └── util/ # Utility classes +└── python/ + ├── main.py # HTTP server entry point + ├── android_utils.py # Android-specific utilities + ├── taskworker.py # Task execution + ├── task_reconciler.py # Task state reconciliation + ├── task_status.py # Task status updates + └── android_app_plugin/ # Kolibri plugin for Android + └── kolibri_plugin.py # StorageHook for task scheduling +``` +## Building -## Running the apk from the terminal +### Debug Build -1. Run `adb shell am start -n org.learningequality.Kolibri/org.kivy.android.PythonActivity` +```bash +make kolibri.apk.unsigned +``` -### Server Side -Run `adb logcat -v brief python:D *:F` to get all debug logs from the Kolibri server +Output: `dist/kolibri-*.apk` -### Client side -1. Start the Kolibri server via Android app -2. Open a browser and see debug logs - - If your device doesn't aggressively kill the server, you can open Chrome and use remote debugging tools to see the logs on your desktop. - - You can also leave the app open and port forward the Android device's Kolibri port using [adb](https://developer.android.com/studio/command-line/adb#forwardports): - ``` - adb forward tcp:8080 tcp:8081 - ``` - then going into your desktop's browser and accessing `localhost:8081`. Note that you can map to any port on the host machine, the second argument. +### Release Build -Alternatively, you can debug the webview directly. Modern Android versions should let you do so from the developer settings. +```bash +export RELEASE_KEYSTORE=/path/to/keystore.jks +export RELEASE_KEYALIAS=key-alias +export RELEASE_KEYSTORE_PASSWD=keystore-password +export RELEASE_KEYALIAS_PASSWD=key-password +make kolibri.apk +``` -You could also do so using [Weinre](https://people.apache.org/~pmuellr/weinre/docs/latest/Home.html). Visit the site to learn how to install and setup. You will have to build a custom Kolibri .whl file that contains the weinre script tag in the [base.html file](https://github.com/learningequality/kolibri/blob/develop/kolibri/core/templates/kolibri/base.html). +### All Make Targets + +```bash +make help # Show all available targets +make setup # Setup SDK, NDK, and emulator +make kolibri.apk.unsigned # Build debug APK +make kolibri.apk # Build release APK (requires signing keys) +make kolibri.aab # Build release AAB for Play Store +make install # Install to connected device/emulator +make test # Run unit tests +make lint # Run Android linter +make emulator # Start the emulator +make logcat # View Kolibri-specific logs +make clean # Clean build artifacts +``` +## Testing -## Helpful commands -- [adb](https://developer.android.com/studio/command-line/adb) is pretty helpful. Here are some useful uses: - - `adb logcat -b all -c` will clear out the device's log. ([Docs](https://developer.android.com/studio/command-line/logcat)) - - Logcat also has a large variety of filtering options. Check out the docs for those. - - Uninstall from terminal using `adb shell pm uninstall org.learningequality.Kolibri`. ([Docs](https://developer.android.com/studio/command-line/adb#pm)) -- Docker shouldn't be rebuilding very often, so it shouldn't be using that much storage. But if it does, you can run `docker system prune` to clear out all "dangling" images, containers, and layers. If you've been constantly rebuilding, it will likely get you several gigabytes of storage. +```bash +# Run unit tests +make test -## Build on Docker +# Start emulator and install +make emulator +make install -This project was previously developed on Docker, but this method has not recently been tested. +# View logs +make logcat +``` -1. Install [docker](https://www.docker.com/community-edition) +## Debugging -2. Build or download a Kolibri WHL file, and place in the `whl/` directory. +```bash +# Kolibri logs +adb logcat -s Kolibri*:V -3. Run `make run_docker`. +# Python logs +adb logcat -v brief python:D *:F -4. The generated APK will end up in the `bin/` folder. +# Task worker process +adb logcat --pid=$(adb shell pidof -s org.learningequality.Kolibri:task_worker) -## Docker Implementation Notes -The image was optimized to limit rebuilding and to be run in a developer-centric way. `scripts/rundocker.sh` describes the options needed to get the build running properly. +# WebView debugging: Chrome → chrome://inspect +``` -Unless you need to make edits to the build method or are debugging one of the build dependencies and would like to continue using docker, you shouldn't need to modify that script. +## Common Issues -## Getting a Python shell within the running app context +**Build fails**: Run `make setup` to ensure SDK is configured correctly +```bash +make clean && make setup +``` -We implemented code for an SSH server that allows connecting into a running Kolibri Android app and running code in an interactive Python shell. You can use this for developing, testing, and debugging Python code running inside the Android and Kolibri environments, which is handy especially for testing out Pyjnius code, checking environment variables, etc. This will soon be implemented as an Android service that can be turned on over ADB, but in the meantime you can use it a bit like you might use `import ipdb; ipdb.set_trace()` to get an interactive shell at a particular context in your code, as follows: +**No notifications**: Grant notification permission in Android settings (required on Android 13+) -- Drop `import remoteshell` at the spot you want to have the shell get dropped in, and build/run the app. -- Connect the device over ADB, e.g. via USB. -- Run `adb forward tcp:4242 tcp:4242` (needs to be re-run if you disconnect and reconnect the device) -- Run `ssh -p 4242 localhost` -- If the device isn’t provisioned, any username/password will be accepted. Otherwise, use the admin credentials. -- If you get an error about “ssh-rsa”, you can put the following SSH config in: +**Tasks not running**: Check WorkManager state +```bash +adb logcat -s BaseTaskWorker:V WorkController:V ``` -Host kolibri-android - HostName localhost - Port 4242 - PubkeyAcceptedAlgorithms +ssh-rsa - HostkeyAlgorithms +ssh-rsa + +**Emulator won't start**: Check available AVDs and recreate if needed +```bash +make list-avds +make avd # Recreate AVD ``` -Then, you should be able to just do “ssh kolibri-android” -## Updating Python for Android +## Contributing + +1. Create feature branch +2. Make changes +3. Run linting: `prek run --all-files` +4. Run tests: `make test` +5. Submit pull request + +## License + +See [LICENSE](LICENSE) file. -We maintain a fork of Python for Android that includes various changes we have made to the source code to support our specific needs. As P4A make new releases, we make a branch from the latest release tag, and then replay the commits on top of this tag using an interactive rebase. Sometimes, this allows us to drop commits as new features are merged into P4A. Our naming convention for the branch on our fork is `from_upstream_`. Any time we push new commits to this branch, we must also update the pinned commit in `requirements.txt`, so that we are always building with a completely predictable version of Python for Android. +## Support -By default we stash any updates to our bootstrap coming from Python for Android, because mostly we have overwritten their bootstrap code to make the relevant changes for us. If there are upstream changes to code we have committed in this repo from the bootstraps, then if the diff is small, it is probably simplest to manually copy in these changes to our committed code. If the diff is larger, or the developer fancies exercising some git-fu, then the make command `make update_project_from_p4a` will update the bootstrap from Python for Android, and not stash any changes that introduces. Through judicious change reversion and diffing, the appropriate changes can then be applied. Here be dragons. +- **Issues**: https://github.com/learningequality/kolibri-installer-android/issues +- **Kolibri Docs**: https://kolibri.readthedocs.io/ +- **Chaquopy Docs**: https://chaquo.com/chaquopy/doc/current/ From 9206ff6f4f67adabff4e6d333fe7e19927c9d3a8 Mon Sep 17 00:00:00 2001 From: Richard Tibbles Date: Fri, 20 Feb 2026 18:30:41 -0800 Subject: [PATCH 6/7] Add development and testing tooling - CLAUDE.md and AGENTS.md for AI-assisted development workflow - CDP helper (scripts/cdp_helper.py) for WebView DOM inspection and interaction via Chrome DevTools Protocol over ADB - Unit tests for CDP helper - Maestro smoke test for app launch verification Co-Authored-By: Claude Opus 4.6 --- .maestro/smoke.yaml | 34 ++++ AGENTS.md | 305 +++++++++++++++++++++++++++++++ CLAUDE.md | 1 + scripts/__init__.py | 0 scripts/cdp_helper.py | 163 +++++++++++++++++ scripts/tests/__init__.py | 0 scripts/tests/test_cdp_helper.py | 251 +++++++++++++++++++++++++ 7 files changed, 754 insertions(+) create mode 100644 .maestro/smoke.yaml create mode 100644 AGENTS.md create mode 100644 CLAUDE.md create mode 100644 scripts/__init__.py create mode 100755 scripts/cdp_helper.py create mode 100644 scripts/tests/__init__.py create mode 100644 scripts/tests/test_cdp_helper.py diff --git a/.maestro/smoke.yaml b/.maestro/smoke.yaml new file mode 100644 index 00000000..f1f5014c --- /dev/null +++ b/.maestro/smoke.yaml @@ -0,0 +1,34 @@ +appId: org.learningequality.Kolibri +# Don't add `androidWebViewHierarchy: devtools`. Modern Chrome WebView exposes +# DOM text via Android's accessibility tree, so we don't need it — and on slow +# CI emulators (API 35 in particular) the CDP queries hang past Maestro's +# websocket timeout while the WebView is still booting, failing the flow. +--- +# Maestro auto-grants every manifest-declared permission during launchApp, so +# POST_NOTIFICATIONS is already granted and no permission dialog appears. +- launchApp + +- extendedWaitUntil: + visible: "How are you using Kolibri?" + timeout: 180000 + +# Step 1: "How are you using Kolibri?" — "On my own" is pre-selected +- tapOn: "CONTINUE" + +# Step 2: "Please select the default language" — English is pre-selected +- extendedWaitUntil: + visible: "Please select the default language for Kolibri" + timeout: 10000 +- tapOn: "CONTINUE" + +# Verify we reach the main app. Kolibri shows a "Welcome to Kolibri!" modal on +# first entry to the library — we key on its presence because the library page +# heading varies with viewport ("Your library" on wide screens, "No resources +# available" on the 320x640 CI emulator) and the rest of the page is marked +# aria-hidden by the modal. +- extendedWaitUntil: + visible: "Welcome to Kolibri!" + timeout: 30000 + +# Dismiss the modal so we leave the app in a clean state for follow-up flows. +- tapOn: "CONTINUE" diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..0f0cee2e --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,305 @@ +# Android Emulator Agent Guide + +Instructions for Claude agents working autonomously with the Android emulator. Follow these sections in order when starting from scratch, or jump to the relevant section for ongoing work. + +## 1. Emulator Setup and Launch + +### Check if the emulator is already running +```bash +adb devices +``` +If you see `emulator-5554 device`, the emulator is ready — skip to section 2. +If the list is empty or shows `offline`, you need to start the emulator. + +### First-time setup (only needed once) +If no AVD has been created yet: +```bash +make setup +``` +This downloads the Android SDK, system image, and creates the `kolibri-test` AVD. + +### Start the emulator +```bash +make emulator +``` +This launches the emulator in the background. Wait for it to finish booting: +```bash +adb wait-for-device +adb shell getprop sys.boot_completed +``` +Poll `sys.boot_completed` until it returns `1`. The boot can take 30-60 seconds. + +**If the emulator segfaults or crashes**, it's likely a GPU issue. Start with software rendering instead: +```bash +android_root/emulator/emulator -avd kolibri-test -gpu guest -no-snapshot & +``` + +## 2. Build and Install the App + +This project uses a Makefile as the primary build interface. Run `make help` to see all available targets. + +### Build and install in one step +```bash +make install +``` +This builds the debug APK via Gradle and installs it on the connected emulator. The first build takes several minutes; subsequent builds are faster. + +Watch for: +- **BUILD SUCCESSFUL**: Proceed to install +- **Compilation errors**: Fix before continuing +- **Python errors**: Check Chaquopy output for syntax issues + +Or just build without installing: +```bash +make kolibri.apk.unsigned +``` + +### Verify the app is installed +```bash +adb shell pm list packages | grep kolibri +``` +Should output: `package:org.learningequality.Kolibri` + +### Launch the app +```bash +adb shell am start -n org.learningequality.Kolibri/org.learningequality.Kolibri.WebViewActivity +``` + +### Force stop the app +```bash +adb shell am force-stop org.learningequality.Kolibri +``` + +### Clear app data (resets to fresh state) +```bash +adb shell pm clear org.learningequality.Kolibri +``` +This is needed after Python code changes since Chaquopy caches bytecode. + +### Uninstall and reinstall (for signing key mismatches) +```bash +make uninstall && make install +``` + +### Makefile reference + +| Target | Description | +|--------|-------------| +| `make setup` | Complete SDK + emulator setup (first time) | +| `make emulator` | Start the emulator | +| `make kolibri.apk.unsigned` | Build debug APK to dist/ | +| `make install` | Build and install debug APK | +| `make uninstall` | Uninstall app from device | +| `make logcat` | View filtered Kolibri logs | +| `make clean` | Clean build artifacts | +| `make test` | Run unit tests | +| `make lint` | Run Android linter | + +### Quick commands + +Build + Install + Launch: +```bash +make install && adb shell am start -n org.learningequality.Kolibri/org.learningequality.Kolibri.WebViewActivity +``` + +Clear logs and monitor: +```bash +adb logcat -c && make logcat +``` + +## 3. Visual Inspect-Act Loop + +This is the core workflow for autonomous UI interaction. Use `/project:screenshot` to run the full loop with instructions, or follow these steps: + +### Capture the screen +```bash +mkdir -p /tmp/claude +adb exec-out screencap -p > /tmp/claude/screenshot.png +``` +Read the screenshot image at `/tmp/claude/screenshot.png` to see the screen visually. + +### Inspect: CDP vs uiautomator + +Kolibri is a WebView app. **WebView content and native Android UI require different tools:** + +| What you see | Tool | Why | +|---|---|---| +| Kolibri UI (buttons, forms, nav, text) | `python3 scripts/cdp_helper.py dump` | WebView DOM is invisible to uiautomator | +| Native Android dialogs (permissions, system prompts) | `adb shell uiautomator dump /sdcard/window_dump.xml && adb shell cat /sdcard/window_dump.xml` | System dialogs are invisible to CDP | + +**Rule of thumb:** If a system dialog with rounded corners is overlaying the app, use uiautomator. For everything else, use CDP. + +### Interact: CDP vs adb input + +**WebView elements** — click by text via CDP (no coordinate math needed): +```bash +python3 scripts/cdp_helper.py click "CONTINUE" +python3 scripts/cdp_helper.py click "EXPLORE" +``` + +**Native elements** — tap by coordinates from uiautomator bounds: +```bash +# bounds="[137,1177][943,1331]" → center at (540, 1254) +adb shell input tap 540 1254 +``` + +**Other interactions:** +```bash +adb shell input text "" # Type text (encode spaces as %s) +adb shell input swipe 540 1500 540 500 300 # Scroll down +adb shell input swipe 540 500 540 1500 300 # Scroll up +adb shell input keyevent 4 # Press BACK +adb shell input keyevent 66 # Press ENTER +adb shell input keyevent 3 # Press HOME +``` + +### Verify +Take another screenshot after every interaction. Confirm the UI changed as expected before proceeding. + +### CDP helper reference + +The CDP helper (`scripts/cdp_helper.py`) uses Chrome DevTools Protocol over ADB to access the WebView DOM. Requires `websockets` (`uv pip install websockets`). + +```bash +python3 scripts/cdp_helper.py dump # List visible DOM elements as JSON +python3 scripts/cdp_helper.py click "Button" # Click element by exact text match +python3 scripts/cdp_helper.py js "expr" # Evaluate arbitrary JavaScript +``` + +### Key event codes +| Code | Key | Code | Key | +|------|-----|------|-----| +| 3 | HOME | 4 | BACK | +| 19 | DPAD_UP | 20 | DPAD_DOWN | +| 21 | DPAD_LEFT | 22 | DPAD_RIGHT | +| 61 | TAB | 66 | ENTER | +| 67 | DEL | 111 | ESCAPE | + +## 4. Maestro Flow Development + +Maestro flows live in `.maestro/`. Use them for repeatable UI test sequences. + +### Develop a new flow +1. **Discover UI elements** using the CDP helper: `python3 scripts/cdp_helper.py dump`. Note the `text` content — Maestro matches WebView elements by text when `androidWebViewHierarchy: devtools` is set. +2. **Write the flow** as a YAML file in `.maestro/`: + ```yaml + appId: org.learningequality.Kolibri + androidWebViewHierarchy: devtools + --- + - launchApp + - tapOn: "CONTINUE" + ``` +3. **Run the flow**: + ```bash + ~/.maestro/bin/maestro test .maestro/your-flow.yaml + ``` +4. **Iterate**: If the flow fails, screenshot to see the actual state, adjust selectors or add waits, re-run. + +### Install Maestro (if not present) +```bash +make maestro-install +``` + +### Common Maestro commands +- `launchApp` / `clearState` / `clearKeychain` +- `tapOn: "text"` / `tapOn: { id: "resource-id" }` +- `inputText: "value"` +- `assertVisible: "text"` / `assertNotVisible: "text"` +- `extendedWaitUntil: { visible: "text", timeout: 30000 }` +- `scroll` / `swipe` +- `back` / `hideKeyboard` + +## 5. Log Inspection + +### Kolibri-filtered logs (streaming) +```bash +make logcat +``` + +### Python stdout/stderr +```bash +adb logcat -s python.stdout:V python.stderr:V +``` + +### Specific component tags +```bash +adb logcat -s KolibriWebView:V KolibriServer:V TaskWorkerImpl:V BaseTaskWorker:V +``` + +### Crash logs +```bash +adb logcat -s AndroidRuntime:E +``` + +### Recent log snapshot (non-streaming) +```bash +adb logcat -d -t 50 +``` + +### Clear log buffer +```bash +adb logcat -c +``` + +## 6. Troubleshooting + +### App crashes on startup +1. Check crash logs: `adb logcat -s AndroidRuntime:E` +2. Look for Python import errors: `adb logcat -s python.stderr:V` + +### Python changes not appearing +Chaquopy caches Python bytecode. Clear app data: +```bash +adb shell pm clear org.learningequality.Kolibri +``` + +Or uninstall and reinstall: +```bash +make uninstall && make install +``` + +### INSTALL_FAILED_UPDATE_INCOMPATIBLE +Signing key mismatch. Uninstall first: +```bash +make uninstall && make install +``` + +### Service Worker issues +1. Open Chrome DevTools: `chrome://inspect` +2. Find the Kolibri WebView and inspect +3. Check Application > Service Workers + +### WorkManager tasks not running +Check task logs: +```bash +adb logcat -s TaskWorkerImpl:V BaseTaskWorker:V WM-WorkerWrapper:V +``` + +### Emulator not found +```bash +make setup # Creates SDK + AVD +make emulator +``` + +## 7. Iterating + +1. Make code changes +2. `make install` +3. Launch app and test +4. `make logcat` in another terminal +5. Repeat + +For Python-only changes, builds are fast since Java doesn't need recompilation. + +## Key Facts + +| Fact | Value | +|------|-------| +| Package name | `org.learningequality.Kolibri` | +| Main activity | `org.learningequality.Kolibri.WebViewActivity` | +| AVD name | `kolibri-test` | +| JAVA_HOME | Set by Makefile; see `JAVA_HOME` in `Makefile` | +| GPU workaround | Use `-gpu guest` if default GPU segfaults | +| Python caching | Clear app data after Python changes (Chaquopy caches bytecode) | +| Build system | Gradle via Makefile wrappers — use `make` targets | +| CDP helper | `python3 scripts/cdp_helper.py` — requires `websockets` package | diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..43c994c2 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md diff --git a/scripts/__init__.py b/scripts/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/scripts/cdp_helper.py b/scripts/cdp_helper.py new file mode 100755 index 00000000..f9a94a70 --- /dev/null +++ b/scripts/cdp_helper.py @@ -0,0 +1,163 @@ +#!/usr/bin/env python3 +""" +CDP helper for inspecting and interacting with Kolibri's WebView. + +Uses Chrome DevTools Protocol over ADB to access the WebView's DOM, +which is not visible to uiautomator. Requires the `websockets` Python +package (`uv pip install websockets`). + +Usage: + python3 scripts/cdp_helper.py dump # List visible DOM elements + python3 scripts/cdp_helper.py click CONTINUE # Click element by text + python3 scripts/cdp_helper.py js "document.title" # Run arbitrary JS +""" + +import asyncio +import json +import os +import subprocess +import sys +import urllib.request + +import websockets + + +def get_ws_url(): + """Find the WebView DevTools WebSocket URL via ADB.""" + output = subprocess.check_output( + ["adb", "shell", "cat /proc/net/unix"], + text=True, + ) + socket_name = None + for line in output.splitlines(): + if "webview_devtools_remote_" in line: + if "@" not in line: + continue + socket_name = line.split("@")[1].strip() + break + if not socket_name: + print( + "ERROR: No WebView devtools socket found. Is the app running?", + file=sys.stderr, + ) + sys.exit(1) + + port = os.environ.get("CDP_PORT", "9222") + subprocess.run( + ["adb", "forward", f"tcp:{port}", f"localabstract:{socket_name}"], + capture_output=True, + check=True, + ) + with urllib.request.urlopen(f"http://localhost:{port}/json") as resp: + pages = json.loads(resp.read()) + if not pages: + print("ERROR: No WebView pages found", file=sys.stderr) + sys.exit(1) + ws_url = pages[0].get("webSocketDebuggerUrl") + if not ws_url: + print( + "ERROR: Page has no webSocketDebuggerUrl — is the WebView debuggable?", + file=sys.stderr, + ) + sys.exit(1) + return ws_url + + +async def run_js(ws_url, js): + """Send a JavaScript expression to the WebView via CDP and return the result.""" + async with websockets.connect(ws_url) as ws: + await ws.send( + json.dumps( + { + "id": 1, + "method": "Runtime.evaluate", + "params": {"expression": js, "returnByValue": True}, + } + ) + ) + resp = json.loads(await ws.recv()) + inner = resp.get("result", {}) + if inner.get("exceptionDetails"): + details = inner["exceptionDetails"] + print( + f"CDP exception: {details.get('text', details)}", + file=sys.stderr, + ) + return None + result = inner.get("result", {}) + if result.get("type") == "undefined": + return None + return result.get("value") + + +def dump_elements(ws_url): + """Print all visible DOM elements with their text, id, classes, and role.""" + js = """JSON.stringify( + Array.from(document.querySelectorAll('*')) + .filter(el => { + const r = el.getBoundingClientRect(); + return r.width > 0 && r.height > 0; + }) + .map(el => ({ + tag: el.tagName.toLowerCase(), + id: el.id || undefined, + text: (el.innerText || '').trim().substring(0, 150) || undefined, + classes: el.className || undefined, + role: el.getAttribute('role') || undefined, + type: el.getAttribute('type') || undefined + })) + .filter(el => el.text || el.id || el.role) + )""" + result = asyncio.run(run_js(ws_url, js)) + if result is None: + print("ERROR: Failed to retrieve DOM elements", file=sys.stderr) + return + elements = json.loads(result) + for el in elements: + el = {k: v for k, v in el.items() if v} + print(json.dumps(el)) + + +def click_by_text(ws_url, text): + """Click the first interactive element matching the given text.""" + safe_text = json.dumps(text) + js = f""" + (function() {{ + const target = {safe_text}; + const els = Array.from(document.querySelectorAll( + 'button, a, [role="button"], label, input' + )); + const el = els.find(e => e.innerText && e.innerText.trim() === target); + if (el) {{ el.click(); return 'clicked: ' + target; }} + return 'not found: ' + target; + }})() + """ + result = asyncio.run(run_js(ws_url, js)) + print(result) + + +def main(): + ws_url = get_ws_url() + cmd = sys.argv[1] if len(sys.argv) > 1 else "dump" + + if cmd == "dump": + dump_elements(ws_url) + elif cmd == "click": + if len(sys.argv) < 3: + print("Usage: cdp_helper.py click ", file=sys.stderr) + sys.exit(1) + click_by_text(ws_url, " ".join(sys.argv[2:])) + elif cmd == "js": + if len(sys.argv) < 3: + print("Usage: cdp_helper.py js ", file=sys.stderr) + sys.exit(1) + result = asyncio.run(run_js(ws_url, " ".join(sys.argv[2:]))) + print(result) + else: + print(f"Unknown command: {cmd}", file=sys.stderr) + print("Commands: dump, click , js ", file=sys.stderr) + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/scripts/tests/__init__.py b/scripts/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/scripts/tests/test_cdp_helper.py b/scripts/tests/test_cdp_helper.py new file mode 100644 index 00000000..c528cfa0 --- /dev/null +++ b/scripts/tests/test_cdp_helper.py @@ -0,0 +1,251 @@ +"""Tests for cdp_helper.py.""" + +import asyncio +import json +import os +import subprocess +from unittest import mock + +import pytest + +from scripts import cdp_helper + +# ── get_ws_url ────────────────────────────────────────────────────────── + + +FAKE_UNIX_OUTPUT = """\ +Num RefCount Protocol Flags Type St Inode Path +0000000000000000: 00000002 00000000 00010000 0001 01 12345 @webview_devtools_remote_1234 +""" + +FAKE_PAGE_JSON = json.dumps( + [{"webSocketDebuggerUrl": "ws://localhost:9222/devtools/page/ABC"}] +).encode() + + +def _mock_urlopen(data=FAKE_PAGE_JSON): + """Create a mock for urllib.request.urlopen that acts as a context manager.""" + resp = mock.Mock() + resp.read.return_value = data + cm = mock.MagicMock() + cm.__enter__.return_value = resp + return cm + + +FAKE_UNIX_OUTPUT_MALFORMED = """\ +Num RefCount Protocol Flags Type St Inode Path +0000000000000000: 00000002 00000000 00010000 0001 01 11111 webview_devtools_remote_bad +0000000000000000: 00000002 00000000 00010000 0001 01 12345 @webview_devtools_remote_1234 +""" + + +def test_get_ws_url_happy_path(): + """get_ws_url returns the WebSocket URL from the first page.""" + with ( + mock.patch("subprocess.check_output", return_value=FAKE_UNIX_OUTPUT), + mock.patch("subprocess.run") as mock_run, + mock.patch("urllib.request.urlopen", return_value=_mock_urlopen()), + ): + url = cdp_helper.get_ws_url() + + assert url == "ws://localhost:9222/devtools/page/ABC" + mock_run.assert_called_once() + assert mock_run.call_args[1].get("check") is True + + +def test_get_ws_url_adb_forward_failure(): + """get_ws_url propagates CalledProcessError from adb forward.""" + with ( + mock.patch("subprocess.check_output", return_value=FAKE_UNIX_OUTPUT), + mock.patch( + "subprocess.run", + side_effect=subprocess.CalledProcessError(1, "adb"), + ), + ): + with pytest.raises(subprocess.CalledProcessError): + cdp_helper.get_ws_url() + + +def test_get_ws_url_configurable_port(): + """get_ws_url respects CDP_PORT env var.""" + with ( + mock.patch("subprocess.check_output", return_value=FAKE_UNIX_OUTPUT), + mock.patch("subprocess.run") as mock_run, + mock.patch( + "urllib.request.urlopen", return_value=_mock_urlopen() + ) as mock_urlopen, + mock.patch.dict(os.environ, {"CDP_PORT": "9333"}), + ): + cdp_helper.get_ws_url() + + fwd_cmd = mock_run.call_args[0][0] + assert "tcp:9333" in fwd_cmd + mock_urlopen.assert_called_once_with("http://localhost:9333/json") + + +def test_get_ws_url_skips_malformed_socket_line(): + """get_ws_url skips lines with no @ and uses the valid one.""" + with ( + mock.patch( + "subprocess.check_output", return_value=FAKE_UNIX_OUTPUT_MALFORMED + ), + mock.patch("subprocess.run"), + mock.patch("urllib.request.urlopen", return_value=_mock_urlopen()), + ): + url = cdp_helper.get_ws_url() + + assert url == "ws://localhost:9222/devtools/page/ABC" + + +def test_get_ws_url_empty_pages(): + """get_ws_url exits when /json returns an empty list.""" + with ( + mock.patch("subprocess.check_output", return_value=FAKE_UNIX_OUTPUT), + mock.patch("subprocess.run"), + mock.patch( + "urllib.request.urlopen", + return_value=_mock_urlopen(data=json.dumps([]).encode()), + ), + ): + with pytest.raises(SystemExit): + cdp_helper.get_ws_url() + + +def test_get_ws_url_missing_ws_debugger_url(): + """get_ws_url exits when page entry lacks webSocketDebuggerUrl.""" + page_no_ws = json.dumps( + [{"title": "Kolibri", "url": "http://localhost"}] + ).encode() + with ( + mock.patch("subprocess.check_output", return_value=FAKE_UNIX_OUTPUT), + mock.patch("subprocess.run"), + mock.patch( + "urllib.request.urlopen", + return_value=_mock_urlopen(data=page_no_ws), + ), + ): + with pytest.raises(SystemExit): + cdp_helper.get_ws_url() + + +# ── run_js ────────────────────────────────────────────────────────────── + + +def _make_ws_mock(cdp_response): + """Create a mock websocket connection returning the given CDP response.""" + mock_ws = mock.AsyncMock() + mock_ws.recv.return_value = cdp_response + mock_connect = mock.AsyncMock() + mock_connect.__aenter__.return_value = mock_ws + return mock_connect + + +def test_run_js_returns_value(): + """run_js returns the value from a successful CDP response.""" + cdp_response = json.dumps( + {"id": 1, "result": {"result": {"type": "string", "value": "hello"}}} + ) + with mock.patch( + "websockets.connect", return_value=_make_ws_mock(cdp_response) + ): + result = asyncio.run(cdp_helper.run_js("ws://fake", "1+1")) + + assert result == "hello" + + +def test_run_js_returns_none_for_undefined(): + """run_js returns None when the result type is undefined.""" + cdp_response = json.dumps( + {"id": 1, "result": {"result": {"type": "undefined"}}} + ) + with mock.patch( + "websockets.connect", return_value=_make_ws_mock(cdp_response) + ): + result = asyncio.run(cdp_helper.run_js("ws://fake", "void 0")) + + assert result is None + + +def test_run_js_handles_exception_details(capsys): + """run_js returns None and prints error when CDP reports exceptionDetails.""" + cdp_response = json.dumps( + { + "id": 1, + "result": { + "result": {"type": "object", "subtype": "error"}, + "exceptionDetails": { + "text": "Uncaught ReferenceError: foo is not defined" + }, + }, + } + ) + with mock.patch( + "websockets.connect", return_value=_make_ws_mock(cdp_response) + ): + result = asyncio.run(cdp_helper.run_js("ws://fake", "foo")) + + assert result is None + captured = capsys.readouterr() + assert "exception" in captured.err.lower() + + +# ── dump_elements ─────────────────────────────────────────────────────── + + +def test_dump_elements_none_guard(capsys): + """dump_elements prints error and doesn't crash when run_js returns None.""" + with mock.patch("scripts.cdp_helper.asyncio") as mock_asyncio: + mock_asyncio.run.return_value = None + cdp_helper.dump_elements("ws://fake") + + captured = capsys.readouterr() + assert "error" in captured.err.lower() + + +def test_dump_elements_prints_elements(capsys): + """dump_elements prints each element as a JSON line.""" + elements = [ + {"tag": "button", "text": "OK"}, + {"tag": "a", "text": "Link", "id": "link1"}, + ] + with mock.patch("scripts.cdp_helper.asyncio") as mock_asyncio: + mock_asyncio.run.return_value = json.dumps(elements) + cdp_helper.dump_elements("ws://fake") + + captured = capsys.readouterr() + lines = captured.out.strip().split("\n") + assert len(lines) == 2 + assert json.loads(lines[0]) == {"tag": "button", "text": "OK"} + + +# ── click_by_text ─────────────────────────────────────────────────────── + + +def test_click_by_text_escapes_special_chars(): + """click_by_text properly escapes quotes, backslashes, and newlines.""" + captured_args = [] + + async def fake_run_js(_ws_url, js): + captured_args.append(js) + return "clicked" + + with mock.patch.object(cdp_helper, "run_js", fake_run_js): + cdp_helper.click_by_text("ws://fake", 'it\'s a "test"\nwith\\backslash') + + assert len(captured_args) == 1 + sent_js = captured_args[0] + # The json-encoded text should appear in the JS (json.dumps includes quotes) + encoded = json.dumps('it\'s a "test"\nwith\\backslash') + assert encoded in sent_js + # No raw unescaped single quotes in JS string context + assert "\\'" not in sent_js + + +def test_click_by_text_simple(capsys): + """click_by_text sends correct JS and prints the result.""" + with mock.patch("scripts.cdp_helper.asyncio") as mock_asyncio: + mock_asyncio.run.return_value = "clicked: CONTINUE" + cdp_helper.click_by_text("ws://fake", "CONTINUE") + + captured = capsys.readouterr() + assert "clicked: CONTINUE" in captured.out From ef2bca8e28fa0adb4c14b976233c1fa0d3673a2e Mon Sep 17 00:00:00 2001 From: Richard Tibbles Date: Mon, 23 Feb 2026 07:35:49 -0800 Subject: [PATCH 7/7] Use preinstalled Android SDK on GitHub Actions runner Remove the apt dependencies step (buildozer-era packages not needed by Chaquopy) and the make setup step (SDK, NDK, build-tools, platform-tools are all preinstalled on ubuntu-latest). The runner provides ANDROID_SDK_ROOT which the Makefile already respects. Co-Authored-By: Claude Opus 4.6 --- .github/workflows/build_apk.yml | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/.github/workflows/build_apk.yml b/.github/workflows/build_apk.yml index e3af1784..c11b0874 100644 --- a/.github/workflows/build_apk.yml +++ b/.github/workflows/build_apk.yml @@ -130,9 +130,7 @@ jobs: name: ${{ inputs.tar-file-name }} path: tar - name: Install dependencies - run: uv pip install --system --extra build -r requirements.txt - - name: Ensure that Android SDK dependencies are installed - run: make setup + run: uv sync --extra build && uv pip install -r requirements.txt - name: Build the aab if: ${{ inputs.release == true || github.event.inputs.release == 'true' }} env: