Skip to content

Commit 61cbf40

Browse files
committed
Add new classes for WebView-based architecture
Add new Java classes for the WebView-based UI architecture: - WebViewActivity: Main activity displaying Kolibri via WebView - KolibriServerService: Foreground service managing Kolibri server - KolibriServerViewModel: ViewModel for server lifecycle management - KolibriWebChromeClient: Custom WebChromeClient for file uploads - KolibriEnvironmentSetup: Chaquopy Python environment setup - KolibriConstants: Application constants Add new utility classes: - AuthUtils: Authentication token management via Chaquopy - ShareUtils: Content sharing functionality - BaseTaskWorker: Base class for background workers Add new Python modules: - auth.py: Python-side authentication helpers - task_status.py: Task status management Add new resources: - activity_webview.xml: WebView activity layout - themes.xml: Application theme definitions Part of #197: Migrate App to use Chaquopy
1 parent b316f7d commit 61cbf40

16 files changed

Lines changed: 1476 additions & 0 deletions
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
package org.learningequality.Kolibri;
2+
3+
/** Application-wide constants for Kolibri Android */
4+
public class KolibriConstants {
5+
// Kolibri URLs
6+
public static final String KOLIBRI_SCHEME = "http";
7+
public static final String KOLIBRI_HOST = "kolibri.app";
8+
public static final String KOLIBRI_BASE_URL = KOLIBRI_SCHEME + "://" + KOLIBRI_HOST + "/";
9+
10+
public static final String ZIPCONTENT_HOST = "zipcontent.app";
11+
public static final String ZIPCONTENT_BASE_URL = KOLIBRI_SCHEME + "://" + ZIPCONTENT_HOST + "/";
12+
13+
// Localhost URLs (for HTTP server)
14+
public static final String LOCALHOST_HOST = "127.0.0.1";
15+
public static final int DEFAULT_KOLIBRI_PORT = 5000;
16+
17+
public static String getLocalUrl(int port) {
18+
return KOLIBRI_SCHEME + "://" + LOCALHOST_HOST + ":" + port + "/";
19+
}
20+
21+
// Session cookie
22+
public static final String KOLIBRI_SESSION_COOKIE = "kolibri";
23+
24+
// Prevent instantiation
25+
private KolibriConstants() {
26+
throw new AssertionError("No instances");
27+
}
28+
}
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
package org.learningequality.Kolibri;
2+
3+
import android.content.Context;
4+
import android.provider.Settings;
5+
import android.util.Log;
6+
import com.chaquo.python.PyObject;
7+
import com.chaquo.python.Python;
8+
import com.chaquo.python.android.AndroidPlatform;
9+
import java.io.File;
10+
import java.util.TimeZone;
11+
import org.learningequality.Kolibri.util.ContextUtil;
12+
13+
/** Sets up the Kolibri environment including environment variables and Python initialization */
14+
public class KolibriEnvironmentSetup {
15+
private static final String TAG = "KolibriEnvironmentSetup";
16+
private static boolean initialized = false;
17+
18+
/** Initialize Kolibri environment Starts Python and sets up all required environment variables */
19+
public static synchronized void initializeEnv(Context context) {
20+
if (initialized) {
21+
Log.d(TAG, "Environment already initialized");
22+
return;
23+
}
24+
25+
ContextUtil.init(context);
26+
27+
// Start Python if not already started
28+
if (!Python.isStarted()) {
29+
Log.d(TAG, "Starting Python");
30+
Python.start(new AndroidPlatform(context));
31+
}
32+
33+
// Set environment variables through Python (Chaquopy-compatible approach)
34+
// This ensures Python's os.environ sees the variables
35+
setPythonEnvironmentVariables(context);
36+
37+
// Initialize Kolibri Python modules
38+
initializeKolibri();
39+
40+
initialized = true;
41+
Log.i(TAG, "Kolibri environment initialized successfully");
42+
}
43+
44+
/**
45+
* Set environment variables through Python's os.environ This is the Chaquopy-compatible approach
46+
* that ensures Python can see the variables
47+
*/
48+
private static void setPythonEnvironmentVariables(Context context) {
49+
try {
50+
Python py = Python.getInstance();
51+
PyObject osModule = py.getModule("os");
52+
PyObject environ = osModule.get("environ");
53+
54+
// KOLIBRI_HOME - where Kolibri stores its data
55+
File externalFilesDir = context.getExternalFilesDir(null);
56+
if (externalFilesDir == null) {
57+
// Fallback to internal storage if external storage is unavailable
58+
externalFilesDir = context.getFilesDir();
59+
Log.w(TAG, "External storage unavailable, using internal storage");
60+
}
61+
File kolibriHome = new File(externalFilesDir, "KOLIBRI_DATA");
62+
if (!kolibriHome.exists()) {
63+
if (!kolibriHome.mkdirs()) {
64+
throw new RuntimeException("Failed to create KOLIBRI_HOME directory");
65+
}
66+
}
67+
environ.callAttr("__setitem__", "KOLIBRI_HOME", kolibriHome.getAbsolutePath());
68+
69+
// Version information
70+
String versionName = BuildConfig.VERSION_NAME;
71+
environ.callAttr("__setitem__", "KOLIBRI_APK_VERSION_NAME", versionName);
72+
73+
// Django settings module
74+
environ.callAttr("__setitem__", "DJANGO_SETTINGS_MODULE", "kolibri_app_settings");
75+
76+
// Disable restart hooks (not needed on Android)
77+
environ.callAttr("__setitem__", "KOLIBRI_RESTART_HOOKS", "");
78+
79+
// Timezone
80+
TimeZone tz = TimeZone.getDefault();
81+
environ.callAttr("__setitem__", "TZ", tz.getID());
82+
83+
// Locale
84+
environ.callAttr("__setitem__", "LC_ALL", "en_US.UTF-8");
85+
86+
// Android language
87+
String language = context.getResources().getConfiguration().locale.getLanguage();
88+
environ.callAttr("__setitem__", "ANDROID_LANG", language);
89+
90+
// CherryPy thread pool size (keep it small for mobile)
91+
environ.callAttr("__setitem__", "KOLIBRI_CHERRYPY_THREAD_POOL", "2");
92+
93+
// Run mode (debug vs release)
94+
if (BuildConfig.DEBUG) {
95+
environ.callAttr("__setitem__", "KOLIBRI_RUN_MODE", "android-debug");
96+
} else {
97+
environ.callAttr("__setitem__", "KOLIBRI_RUN_MODE", "");
98+
}
99+
100+
// Auth token
101+
String authToken = org.learningequality.Kolibri.util.AuthUtils.getOrCreateAuthToken();
102+
environ.callAttr("__setitem__", "KOLIBRI_AUTH_TOKEN", authToken);
103+
104+
// Morango node ID (from Android ID)
105+
String androidId =
106+
Settings.Secure.getString(context.getContentResolver(), Settings.Secure.ANDROID_ID);
107+
if (isValidAndroidId(androidId)) {
108+
environ.callAttr("__setitem__", "MORANGO_NODE_ID", androidId);
109+
}
110+
111+
Log.d(TAG, "Python environment variables set successfully");
112+
113+
} catch (Exception e) {
114+
Log.e(TAG, "Error setting Python environment variables", e);
115+
throw new RuntimeException("Failed to set Python environment variables", e);
116+
}
117+
}
118+
119+
private static boolean isValidAndroidId(String androidId) {
120+
if (androidId == null || androidId.length() < 16) {
121+
return false;
122+
}
123+
// Known bad Android ID on some emulators
124+
if ("9774d56d682e549c".equals(androidId)) {
125+
return false;
126+
}
127+
return true;
128+
}
129+
130+
private static void initializeKolibri() {
131+
try {
132+
Python py = Python.getInstance();
133+
134+
// Import Kolibri main module
135+
PyObject kolibriMain = py.getModule("kolibri.main");
136+
137+
// Enable required plugins BEFORE initialize()
138+
// Plugins must be enabled before the registry is initialized
139+
kolibriMain.callAttr("enable_plugin", "kolibri.plugins.app");
140+
kolibriMain.callAttr("enable_plugin", "android_app_plugin");
141+
142+
// Call initialize (skip_update=False means we do run any pending migrations)
143+
kolibriMain.callAttr("initialize", false);
144+
145+
Log.d(TAG, "Kolibri Python modules initialized");
146+
147+
} catch (Exception e) {
148+
Log.e(TAG, "Error initializing Kolibri Python modules", e);
149+
throw new RuntimeException("Failed to initialize Kolibri", e);
150+
}
151+
}
152+
153+
public static boolean isInitialized() {
154+
return initialized;
155+
}
156+
}
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
package org.learningequality.Kolibri;
2+
3+
import android.app.Service;
4+
import android.content.Intent;
5+
import android.os.IBinder;
6+
import android.util.Log;
7+
import androidx.annotation.Nullable;
8+
import com.chaquo.python.PyObject;
9+
import com.chaquo.python.Python;
10+
11+
/**
12+
* Background service that starts the Kolibri HTTP server
13+
*
14+
* <p>Server runs in background thread and signals readiness via ViewModel. Handles both local
15+
* WebView and remote peer connections.
16+
*/
17+
public class KolibriServerService extends Service {
18+
private static final String TAG = "KolibriServerService";
19+
20+
private Thread serverThread;
21+
private volatile boolean isRunning = false;
22+
23+
@Override
24+
public void onCreate() {
25+
super.onCreate();
26+
Log.d(TAG, "KolibriServerService onCreate");
27+
28+
// Initialize Kolibri environment
29+
KolibriEnvironmentSetup.initializeEnv(this);
30+
31+
// Start HTTP server in background thread
32+
startHttpServer();
33+
}
34+
35+
private synchronized void startHttpServer() {
36+
if (isRunning || (serverThread != null && serverThread.isAlive())) {
37+
Log.w(TAG, "Server already running");
38+
return;
39+
}
40+
41+
serverThread =
42+
new Thread(
43+
() -> {
44+
try {
45+
Log.i(TAG, "Starting Kolibri HTTP server");
46+
isRunning = true;
47+
48+
Python py = Python.getInstance();
49+
PyObject mainModule = py.getModule("main");
50+
51+
// This blocks until server stops
52+
mainModule.callAttr("start_server");
53+
54+
Log.i(TAG, "Kolibri HTTP server stopped");
55+
} catch (Exception e) {
56+
Log.e(TAG, "Error running Kolibri HTTP server", e);
57+
} finally {
58+
isRunning = false;
59+
}
60+
},
61+
"KolibriServerThread");
62+
63+
serverThread.start();
64+
Log.d(TAG, "HTTP server thread started");
65+
}
66+
67+
@Override
68+
public void onDestroy() {
69+
super.onDestroy();
70+
Log.d(TAG, "KolibriServerService onDestroy");
71+
72+
isRunning = false;
73+
74+
// Call Python to stop the server gracefully
75+
try {
76+
Python py = Python.getInstance();
77+
PyObject mainModule = py.getModule("main");
78+
mainModule.callAttr("stop_server");
79+
Log.d(TAG, "Called Python stop_server");
80+
} catch (Exception e) {
81+
Log.w(TAG, "Error calling stop_server (may already be stopped)", e);
82+
}
83+
84+
if (serverThread != null && serverThread.isAlive()) {
85+
try {
86+
serverThread.join(5000);
87+
if (serverThread.isAlive()) {
88+
Log.w(TAG, "Server thread did not stop in time, interrupting");
89+
serverThread.interrupt();
90+
}
91+
} catch (InterruptedException e) {
92+
Log.w(TAG, "Interrupted waiting for server thread");
93+
Thread.currentThread().interrupt();
94+
}
95+
}
96+
97+
serverThread = null;
98+
}
99+
100+
@Nullable
101+
@Override
102+
public IBinder onBind(Intent intent) {
103+
// This is a started service, not a bound service
104+
return null;
105+
}
106+
107+
public boolean isRunning() {
108+
return isRunning;
109+
}
110+
}
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
package org.learningequality.Kolibri;
2+
3+
import androidx.lifecycle.LiveData;
4+
import androidx.lifecycle.MutableLiveData;
5+
6+
/**
7+
* Application-level state holder for Kolibri server status.
8+
*
9+
* <p>This is a singleton because Python (via Chaquopy) needs to call setServerReady() from the
10+
* server thread, and Activities need to observe the state via LiveData.
11+
*
12+
* <p>Note: This intentionally does NOT extend ViewModel because: 1. It's accessed from Python code
13+
* which can't use ViewModelProvider 2. The state needs to persist across Activity recreation 3.
14+
* It's application-scoped, not Activity-scoped
15+
*
16+
* <p>LiveData is still used for lifecycle-aware observation in Activities.
17+
*/
18+
public class KolibriServerViewModel {
19+
private static volatile KolibriServerViewModel instance;
20+
private final MutableLiveData<ServerInfo> serverReady =
21+
new MutableLiveData<>(new ServerInfo(false, 0, ""));
22+
23+
// Private constructor for singleton
24+
private KolibriServerViewModel() {}
25+
26+
/** Get the singleton instance. Thread-safe double-checked locking. */
27+
public static KolibriServerViewModel getInstance() {
28+
if (instance == null) {
29+
synchronized (KolibriServerViewModel.class) {
30+
if (instance == null) {
31+
instance = new KolibriServerViewModel();
32+
}
33+
}
34+
}
35+
return instance;
36+
}
37+
38+
/**
39+
* Set server ready state. Called from Python when server starts. Uses postValue() for
40+
* thread-safety (can be called from any thread).
41+
*/
42+
public void setServerReady(boolean ready, int port, String initUrl) {
43+
serverReady.postValue(new ServerInfo(ready, port, initUrl));
44+
}
45+
46+
/**
47+
* Get LiveData for observing server state. Activities should observe this with their lifecycle.
48+
*/
49+
public LiveData<ServerInfo> getServerReadyLiveData() {
50+
return serverReady;
51+
}
52+
53+
/** Reset server state (e.g., when server stops). */
54+
public void resetServerState() {
55+
serverReady.postValue(new ServerInfo(false, 0, ""));
56+
}
57+
58+
/** Immutable server info state. */
59+
public static class ServerInfo {
60+
private final boolean ready;
61+
private final int port;
62+
private final String initializationUrl;
63+
64+
public ServerInfo(boolean ready, int port, String initializationUrl) {
65+
this.ready = ready;
66+
this.port = port;
67+
this.initializationUrl = initializationUrl;
68+
}
69+
70+
public boolean isReady() {
71+
return ready;
72+
}
73+
74+
public int getPort() {
75+
return port;
76+
}
77+
78+
public String getInitializationUrl() {
79+
return initializationUrl;
80+
}
81+
}
82+
}

0 commit comments

Comments
 (0)