Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions packages/kolibri/__tests__/heartbeat.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import useUser, { useUserMock } from 'kolibri/composables/useUser'; // eslint-di
import useSnackbar, { useSnackbarMock } from 'kolibri/composables/useSnackbar'; // eslint-disable-line
import { ref } from 'vue';
import { DisconnectionErrorCodes } from 'kolibri/constants';
import { pageVisible } from 'kolibri/utils/browserInfo';
import { HeartBeat } from '../heartbeat.js';
import { trs } from '../internal/disconnection';
import { stubWindowLocation } from 'testUtils'; // eslint-disable-line
Expand Down Expand Up @@ -215,6 +216,30 @@ describe('HeartBeat', function () {
heartBeat.monitorDisconnect();
expect(get(heartBeat._connection.reconnectTime)).toEqual('fork');
});
describe('reconnect backoff', function () {
beforeEach(function () {
// Re-arm after the outer beforeEach's monitorDisconnect()
set(heartBeat._connection.connected, true);
});
afterEach(function () {
set(pageVisible, true);
});
it('should retry soon when a page the user is looking at loses the server', function () {
set(pageVisible, true);
heartBeat.monitorDisconnect();
expect(get(heartBeat._connection.reconnectTime)).toEqual(5);
});
it('should back off completely when the page is not visible', function () {
set(pageVisible, false);
heartBeat.monitorDisconnect();
expect(get(heartBeat._connection.reconnectTime)).toEqual(600);
});
it('should back off for a server overload rather than an unreachable server', function () {
set(pageVisible, true);
heartBeat.monitorDisconnect(502);
expect(get(heartBeat._connection.reconnectTime)).toEqual(60);
});
});
});
describe('_checkSession method', function () {
let heartBeat;
Expand Down
2 changes: 1 addition & 1 deletion packages/kolibri/heartbeat.js
Original file line number Diff line number Diff line change
Expand Up @@ -234,7 +234,7 @@ export class HeartBeat {
// We have not already registered that we have been disconnected
set(this._connection.connected, false);
let reconnectionTime;
if (get(pageVisible)) {
if (!get(pageVisible)) {
// If current page is not visible, back off completely
// user can force reconnect with interface when they return
reconnectionTime = MAX_RECONNECT_TIME;
Expand Down
3 changes: 3 additions & 0 deletions platforms/android/app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,9 @@ android {
testOptions {
unitTests {
returnDefaultValues = true
// Robolectric needs the merged manifest and the appcompat resources to inflate an
// AppCompatActivity; without them setContentView dies in the vector-drawable setup.
includeAndroidResources = true
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -85,8 +85,9 @@ private synchronized void startHttpServer() {
Python py = Python.getInstance();
PyObject mainModule = py.getModule("main");

// This blocks until server stops
mainModule.callAttr("start_server");
// Same process as the activity, so the port the live page is on is readable here
// (0 on a cold start). Blocks until the server stops.
mainModule.callAttr("start_server", WebViewLocation.getLastPort());

Log.i(TAG, "Kolibri HTTP server stopped");
} catch (Exception e) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@
import android.Manifest;
import android.content.ActivityNotFoundException;
import android.content.Intent;
import android.content.SharedPreferences;
import android.content.pm.PackageManager;
import android.graphics.Bitmap;
import android.graphics.drawable.AnimatedVectorDrawable;
import android.graphics.drawable.Drawable;
import android.net.Uri;
Expand All @@ -16,6 +16,7 @@
import android.webkit.URLUtil;
import android.webkit.ValueCallback;
import android.webkit.WebChromeClient;
import android.webkit.WebResourceError;
import android.webkit.WebResourceRequest;
import android.webkit.WebSettings;
import android.webkit.WebView;
Expand All @@ -26,6 +27,7 @@
import androidx.activity.OnBackPressedCallback;
import androidx.activity.result.ActivityResultLauncher;
import androidx.activity.result.contract.ActivityResultContracts;
import androidx.annotation.VisibleForTesting;
import androidx.appcompat.app.AppCompatActivity;
import androidx.core.app.ActivityCompat;
import androidx.core.content.ContextCompat;
Expand All @@ -43,13 +45,14 @@
*
* <p>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.
*
* <p>Android stops the server service once the app has been idle, so the page is frozen while the
* activity is stopped and reloaded only if the server comes back on a different port.
*/
public class WebViewActivity extends AppCompatActivity {
private static final String TAG = "WebViewActivity";
private static final int REQUEST_NOTIFICATION_PERMISSION = 1001;
private static final int REQUEST_STORAGE_PERMISSION = 1002;
private static final String PREFS_NAME = "kolibri_webview";
private static final String PREF_LAST_PATH = "last_path";
private static final String LOOPBACK_IP = "127.0.0.1";
private static final String LOCALHOST = "localhost";

Expand All @@ -58,6 +61,7 @@ public class WebViewActivity extends AppCompatActivity {
private FrameLayout fullscreenContainer;
private View splashContainer;
private boolean shouldClearHistory;
private boolean mainFrameLoadFailed;
private ValueCallback<Uri[]> pendingFilePickerCallback;
private ActivityResultLauncher<String[]> filePickerLauncher;
private final ExecutorService downloadExecutor = Executors.newCachedThreadPool();
Expand All @@ -81,6 +85,38 @@ protected void onStart() {
super.onStart();
// Restart the server service if Android stopped it while the app was idle
startService(new Intent(this, KolibriServerService.class));
// LiveData does not re-deliver an unchanged value on returning to STARTED, so nothing else
// would thaw a page whose server never went away. A restart is thawed by the observer instead.
if (Boolean.TRUE.equals(
KolibriServerViewModel.getInstance().getServerReadyLiveData().getValue())) {
resumeWebView();
}
}

@Override
protected void onStop() {
super.onStop();
pauseWebView();
}

/**
* One failed request is enough for the heartbeat to declare a disconnect and wedge the page
* behind the disconnection snackbar, so nothing is issued while the server may be away. {@code
* pauseTimers()} is process-wide — fine with a single WebView — and stops the heartbeat's
* setTimeout chain without stopping event-driven JS, so a file-chooser result still lands.
*/
private void pauseWebView() {
if (webView != null) {
webView.onPause();
webView.pauseTimers();
}
}

private void resumeWebView() {
if (webView != null) {
webView.resumeTimers();
webView.onResume();
}
}

/**
Expand Down Expand Up @@ -238,6 +274,13 @@ public boolean shouldOverrideUrlLoading(WebView view, WebResourceRequest request
return true;
}

@Override
public void onPageStarted(WebView view, String url, Bitmap favicon) {
// Scopes mainFrameLoadFailed to this document load; same-document (hash) navigations
// never reach here, so a route change cannot clear a load failure.
mainFrameLoadFailed = false;
}

@Override
public void onPageFinished(WebView view, String url) {
// Injecting from onPageStarted doesn't reliably land in the new document on older
Expand All @@ -262,7 +305,22 @@ public void onPageFinished(WebView view, String url) {
if (url != null && !url.startsWith("data:")) {
hideSplash();
}
saveLastPath(url);
WebViewLocation.save(WebViewActivity.this, url);
}

@Override
public void onReceivedError(
WebView view, WebResourceRequest request, WebResourceError error) {
if (request.isForMainFrame()) {
mainFrameLoadFailed = true;
}
}

@Override
public void doUpdateVisitedHistory(WebView view, String url, boolean isReload) {
// Kolibri's router is in hash mode: route changes are same-document navigations and
// never reach onPageFinished, so this is what keeps the saved fragment current.
WebViewLocation.save(WebViewActivity.this, url);
}
});

Expand Down Expand Up @@ -353,53 +411,51 @@ private void loadKolibri() {
.observe(
this,
ready -> {
// Nothing on the way down: resetServerState() is posted whenever the server stops,
// and freezing a foregrounded page on every stop is more than this needs.
if (!ready) {
return;
}
// Resume before loading — loadUrl on a paused WebView is not reliable.
resumeWebView();
loadInitializeUrl();
});
}

/** Build the initialization URL on a background thread (calls Python) and load it. */
/** Builds the initialization URL on a background thread, because it calls Python. */
private void loadInitializeUrl() {
// 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);

new Thread(
() -> {
// Reading the saved path hits SharedPreferences, so keep it off the UI thread too.
String nextUrl = WebViewLocation.getLastPath(this);
Log.d(TAG, "Server ready, restoring path: " + nextUrl);
String url =
Python.getInstance()
.getModule("main")
.callAttr("get_initialize_url", nextUrl)
.toString();
runOnUiThread(
() -> {
if (webView != null) {
shouldClearHistory = true;
webView.loadUrl(url);
}
});
runOnUiThread(() -> loadIfOriginChanged(url));
})
.start();
}

/**
* Save the last loaded path from a localhost URL for restoring on next launch. Skips
* non-localhost URLs and data: URLs.
* {@code url} is the initialize URL on the port the server just came up on, so an unchanged
* origin means the live page is still good. A failed load is not a page worth keeping — otherwise
* the restart leaves the user on the WebView's own error page.
*/
private void saveLastPath(String url) {
if (url == null || url.startsWith("data:")) {
@VisibleForTesting
void loadIfOriginChanged(String url) {
if (webView == null) {
return;
}
Uri uri = Uri.parse(url);
if (!LOOPBACK_IP.equals(uri.getHost())) {
if (!mainFrameLoadFailed && WebViewLocation.isSameOrigin(webView.getUrl(), url)) {
Log.d(TAG, "Server came back on the same origin; leaving the page in place");
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();
shouldClearHistory = true;
WebViewLocation.noteInitializeUrl(url);
webView.loadUrl(url);
}

/** Hide the splash screen */
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
package org.learningequality.Kolibri;

import android.content.Context;
import android.content.SharedPreferences;
import android.net.Uri;

/**
* Where the WebView last was: the path to restore on a cold start, and the port the live page is
* loaded on so a restarted server can come back on it.
*/
final class WebViewLocation {
private static final String PREFS_NAME = "kolibri_webview";
private static final String PREF_LAST_PATH = "last_path";
private static final String LOOPBACK_IP = "127.0.0.1";

/**
* A static, not a preference: a remembered port must not outlive the process, because a cold
* start still has to elect an ephemeral one.
*/
private static volatile int lastPort;

private static volatile String initializePath;

private WebViewLocation() {}

/**
* Makes {@link #save} skip the initialize URL: it 302s straight to the real page, and saving it
* would nest a spent auth_token and a stale next inside the next one.
*/
static void noteInitializeUrl(String url) {
initializePath = Uri.parse(url).getPath();
}

/**
* Record a URL as the page to come back to. Skips anything that is not a page served by our own
* loopback server.
*
* <p>Main-frame URLs only: a {@code zipcontent} subframe URL passes the loopback host check but
* carries the ZIP server's port, which would then be tried as the main server port.
*/
static void save(Context context, String url) {
if (url == null) {
return;
}
Uri uri = Uri.parse(url);
int port = uri.getPort();
String origin = origin(uri);
if (!LOOPBACK_IP.equals(uri.getHost()) || port <= 0 || origin == null) {
return;
}
if (initializePath != null && initializePath.equals(uri.getPath())) {
return;
}
// substring, not getPath(), so the query and fragment survive intact
String path = url.substring(origin.length());
lastPort = port;
SharedPreferences prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE);
// Called on every navigation, and the framework blocks in onStop on each queued apply(), so
// skip the writes that would change nothing.
if (!path.equals(prefs.getString(PREF_LAST_PATH, null))) {
prefs.edit().putString(PREF_LAST_PATH, path).apply();
}
}

/** The path, query and fragment of the last page loaded, or {@code null}. */
static String getLastPath(Context context) {
return context
.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
.getString(PREF_LAST_PATH, null);
}

/** The port the live page is loaded on, or {@code 0} if this process has not loaded one yet. */
static int getLastPort() {
return lastPort;
}

/** Whether two URLs share a scheme and authority. False if either lacks one. */
static boolean isSameOrigin(String url, String otherUrl) {
if (url == null || otherUrl == null) {
return false;
}
String origin = origin(Uri.parse(url));
return origin != null && origin.equals(origin(Uri.parse(otherUrl)));
}

private static String origin(Uri uri) {
if (uri.getScheme() == null || uri.getAuthority() == null) {
return null;
}
return uri.getScheme() + "://" + uri.getAuthority();
}
}
Loading
Loading