diff --git a/app/app/.gitignore b/app/app/.gitignore
index 42afabfd2abebf31384ca7797186a27a4b7dbee8..a8f6ac7fc126301e975128e31f4e582accac1e20 100644
--- a/app/app/.gitignore
+++ b/app/app/.gitignore
@@ -1 +1,4 @@
-/build
\ No newline at end of file
+/build
+
+# Auto-generated by code in build.gradle.
+src/main/assets/source
\ No newline at end of file
diff --git a/app/app/build.gradle b/app/app/build.gradle
index 8cf92ef88795fbb0a2a518f59585b832de58862c..68903d5baca01512428f2730869667fb7e530ec1 100644
--- a/app/app/build.gradle
+++ b/app/app/build.gradle
@@ -1,7 +1,21 @@
 plugins {
     id 'com.android.application'
     id 'org.jetbrains.kotlin.android'
-    id 'com.chaquo.python'
+    id 'com.chaquo.python'  // Must come after com.android.application
+}
+
+afterEvaluate {
+    def assetsSrcDir = "src/main/assets/source"
+    delete assetsSrcDir
+    mkdir assetsSrcDir
+    for (filename in ["src/main/python/chaquopy/demo/ui_demo.py",
+                      "src/main/java/com/chaquo/python/demo/JavaDemoActivity.java"]) {
+        assert file(filename).exists()
+        copy {
+            from filename
+            into assetsSrcDir
+        }
+    }
 }
 
 android {
@@ -15,51 +29,111 @@ android {
     }
 
     defaultConfig {
-        applicationId "com.example.privatestorage"
+        applicationId "com.chaquo.python.demo3"
         minSdk 27
         targetSdk 32
-        versionCode 1
-        versionName "1.0"
-
-        testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
-
-        ndk {
-            abiFilters "armeabi-v7a", "arm64-v8a", "x86", "x86_64"
-        }
+        versionCode 20221111
+        versionName "2022.11.11"
 
         python {
             version "3.9"
 	    buildPython "python3.9"
+
+            // Android UI demo
+            pip {
+                install "Pygments==2.2.0"   // Also used in Java API demo
+            }
+            staticProxy "chaquopy.demo.ui_demo"
+
+            // Python unit tests
+            pip {
+                // In newer versions, importing murmurhash automatically imports and
+                // extracts murmurhash/mrmr.so, which would complicate the tests.
+                install "murmurhash==0.28.0"  // Requires chaquopy-libcxx
+            }
+            staticProxy("chaquopy.test.static_proxy.basic", "chaquopy.test.static_proxy.header",
+                        "chaquopy.test.static_proxy.method")
+            pyc {
+                // For testing bytecode compilation on device, and also to include test source
+                // code in stack traces.
+                src false
+
+                // For testing bytecode compilation during build.
+                pip true
+            }
         }
-    }
 
-    buildTypes {
-        release {
-            minifyEnabled false
-            proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
+        ndk {
+            abiFilters "armeabi-v7a", "arm64-v8a", "x86_64"
         }
+
+        // Chaquopy generates extra internal-use constructors on static proxy classes.
+        lintOptions {
+            disable "ValidFragment"
+        }
+
+        // Remove other languages imported from Android support libraries.
+        resConfigs "en"
     }
+
     compileOptions {
         sourceCompatibility JavaVersion.VERSION_1_8
         targetCompatibility JavaVersion.VERSION_1_8
     }
-    kotlinOptions {
-        jvmTarget = '1.8'
+
+    // For testing with minifyEnabled (see release/README.md).
+    buildTypes {
+        releaseMinify {
+            initWith release
+            minifyEnabled true
+            proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
+        }
     }
-    buildFeatures {
-        viewBinding true
+
+    def keystore = file('../chaquo.jks')
+    if (keystore.exists()) {
+        signingConfigs {
+            config {
+                storeFile keystore
+                keyAlias 'key0'
+                keyPassword 'android'
+                storePassword 'android'
+            }
+        }
+        buildTypes.all { it.signingConfig signingConfigs.config }
+    }
+
+    sourceSets {
+        for (path in [
+            "/home/exarkun/Work/python/chaquopy/product/runtime/src/test",   // Unit tests
+            "src/utils"                         // Files shared with pkgtest app
+        ]) {
+            main {
+                java { srcDir "$path/java" }
+                python { srcDir "$path/python" }
+                res { srcDir "$path/res" }
+            }
+        }
     }
 }
 
 dependencies {
+    implementation fileTree(dir: 'libs', include: ['*.jar'])
+    // appcompat version 1.2.0 is required to fix an incompatibility with WebView on API level
+    // 21 (https://stackoverflow.com/questions/41025200).
+    implementation 'androidx.appcompat:appcompat:1.2.0-beta01'
+    implementation 'androidx.constraintlayout:constraintlayout:1.1.3'
+    implementation 'androidx.lifecycle:lifecycle-extensions:2.1.0'
+    implementation 'androidx.preference:preference:1.1.1'
+    implementation 'junit:junit:4.12'
+    implementation 'org.hamcrest:hamcrest-library:2.2'
+
+    // I went to https://maven.google.com/web/index.html and searched for
+    // androidx.arch.core and a hit came up.  I clicked on it and guessed that
+    // the testing library is what we need.  I clicked on the newest version
+    // and it gave me an option to copy this string so I did and I put it
+    // HERE!
+    implementation 'androidx.arch.core:core-testing:2.1.0'
 
-    implementation 'androidx.core:core-ktx:1.7.0'
-    implementation 'androidx.appcompat:appcompat:1.3.0'
-    implementation 'com.google.android.material:material:1.4.0'
-    implementation 'androidx.constraintlayout:constraintlayout:2.0.4'
-    implementation 'androidx.navigation:navigation-fragment-ktx:2.3.5'
-    implementation 'androidx.navigation:navigation-ui-ktx:2.3.5'
-    testImplementation 'junit:junit:4.13.2'
-    androidTestImplementation 'androidx.test.ext:junit:1.1.3'
-    androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0'
+    testImplementation 'junit:junit:4.12'
 }
diff --git a/app/app/proguard-rules.pro b/app/app/proguard-rules.pro
index 481bb434814107eb79d7a30b676d344b0df2f8ce..9b00913ae755853e788dee8cdefa2aaa943f0331 100644
--- a/app/app/proguard-rules.pro
+++ b/app/app/proguard-rules.pro
@@ -1,21 +1,26 @@
-# Add project specific ProGuard rules here.
-# You can control the set of applied configuration files using the
-# proguardFiles setting in build.gradle.
-#
 # For more details, see
 #   http://developer.android.com/guide/developing/tools/proguard.html
 
-# If your project uses WebView with JS, uncomment the following
-# and specify the fully qualified class name to the JavaScript interface
-# class:
-#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
-#   public *;
-#}
-
 # Uncomment this to preserve the line number information for
 # debugging stack traces.
-#-keepattributes SourceFile,LineNumberTable
+-keepattributes SourceFile,LineNumberTable
 
 # If you keep the line number information, uncomment this to
 # hide the original source file name.
-#-renamesourcefileattribute SourceFile
\ No newline at end of file
+#-renamesourcefileattribute SourceFile
+
+# Android UI demo
+-keep class chaquopy.demo.ui_demo.** { *; }
+-keep class androidx.appcompat.app.** { *; }
+-keep class androidx.core.app.** { *; }
+-keep class androidx.fragment.app.** { *; }
+-keep class androidx.preference.** { *; }
+
+# Java unit tests
+-keep class com.chaquo.java.** { *; }
+-keep class org.junit.** { *; }
+
+# Python unit tests
+-keep class package1.** { *; }  # TestImport
+-keepattributes Exceptions  # TestProxy.test_exception
+-keep class chaquopy.test.static_proxy.** { *; }  # TestStaticProxy
diff --git a/app/app/src/androidTest/java/com/example/privatestorage/ExampleInstrumentedTest.kt b/app/app/src/androidTest/java/com/example/privatestorage/ExampleInstrumentedTest.kt
deleted file mode 100644
index 5f5382757f2aaaf4bfc25849509d5ca37e2e9b6b..0000000000000000000000000000000000000000
--- a/app/app/src/androidTest/java/com/example/privatestorage/ExampleInstrumentedTest.kt
+++ /dev/null
@@ -1,24 +0,0 @@
-package com.example.privatestorage
-
-import androidx.test.platform.app.InstrumentationRegistry
-import androidx.test.ext.junit.runners.AndroidJUnit4
-
-import org.junit.Test
-import org.junit.runner.RunWith
-
-import org.junit.Assert.*
-
-/**
- * Instrumented test, which will execute on an Android device.
- *
- * See [testing documentation](http://d.android.com/tools/testing).
- */
-@RunWith(AndroidJUnit4::class)
-class ExampleInstrumentedTest {
-    @Test
-    fun useAppContext() {
-        // Context of the app under test.
-        val appContext = InstrumentationRegistry.getInstrumentation().targetContext
-        assertEquals("com.example.privatestorage", appContext.packageName)
-    }
-}
\ No newline at end of file
diff --git a/app/app/src/main/AndroidManifest.xml b/app/app/src/main/AndroidManifest.xml
index b2390170931626c7d88e2fe46e9d5e682e6ed38d..f7c8caf28178d9ee6634b0ffc8b32db8f7a164d5 100644
--- a/app/app/src/main/AndroidManifest.xml
+++ b/app/app/src/main/AndroidManifest.xml
@@ -1,29 +1,45 @@
 <?xml version="1.0" encoding="utf-8"?>
-<manifest xmlns:android="http://schemas.android.com/apk/res/android"
-    xmlns:tools="http://schemas.android.com/tools"
-    package="com.example.privatestorage">
+<manifest package="com.chaquo.python.demo"
+    xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:tools="http://schemas.android.com/tools">
+
+    <uses-permission android:name="android.permission.INTERNET"/>
+    <uses-permission android:name="android.permission.VIBRATE"/>
 
     <application
+        android:name="com.chaquo.python.utils.App"
         android:allowBackup="true"
-        android:dataExtractionRules="@xml/data_extraction_rules"
-        android:fullBackupContent="@xml/backup_rules"
-        android:icon="@mipmap/ic_launcher"
+        android:icon="@drawable/ic_launcher"
         android:label="@string/app_name"
-        android:roundIcon="@mipmap/ic_launcher_round"
         android:supportsRtl="true"
-        android:theme="@style/Theme.PrivateStorage"
-        tools:targetApi="31">
-        <activity
-            android:name=".MainActivity"
-            android:exported="true"
+        android:theme="@style/AppTheme"
+        tools:ignore="AllowBackup">
+        <activity android:name="com.chaquo.python.utils.MainActivity"
             android:label="@string/app_name"
-            android:theme="@style/Theme.PrivateStorage.NoActionBar">
+            android:exported="true">
             <intent-filter>
-                <action android:name="android.intent.action.MAIN" />
-
-                <category android:name="android.intent.category.LAUNCHER" />
+                <action android:name="android.intent.action.MAIN"/>
+                <category android:name="android.intent.category.LAUNCHER"/>
             </intent-filter>
         </activity>
+        <!-- Contrary to documentation, singleTask does not always create a new task:
+             https://issuetracker.google.com/issues/36921210-->
+        <activity android:name="com.chaquo.python.utils.ReplActivity"
+            android:label="@string/repl"
+            android:launchMode="singleInstance">
+        </activity>
+        <activity android:name="chaquopy.demo.ui_demo.UIDemoActivity"
+            android:label="@string/ui_demo">
+        </activity>
+        <activity android:name=".JavaDemoActivity"
+            android:label="@string/java_demo">
+        </activity>
+        <activity android:name=".JavaTestActivity"
+            android:label="@string/java_unit_test">
+        </activity>
+        <activity android:name="com.chaquo.python.utils.PythonTestActivity"
+            android:label="@string/python_unit_test">
+        </activity>
     </application>
 
 </manifest>
\ No newline at end of file
diff --git a/app/app/src/main/java/com/chaquo/python/demo/JavaDemoActivity.java b/app/app/src/main/java/com/chaquo/python/demo/JavaDemoActivity.java
new file mode 100644
index 0000000000000000000000000000000000000000..644aaa8828580f97487d19802808a2f8f48688f4
--- /dev/null
+++ b/app/app/src/main/java/com/chaquo/python/demo/JavaDemoActivity.java
@@ -0,0 +1,63 @@
+package com.chaquo.python.demo;
+
+import android.content.*;
+import android.os.*;
+import android.util.*;
+import android.webkit.*;
+import android.widget.*;
+import androidx.appcompat.app.*;
+import com.chaquo.python.*;
+import java.io.*;
+
+public class JavaDemoActivity extends AppCompatActivity {
+
+    @Override
+    protected void onCreate(Bundle savedInstanceState) {
+        super.onCreate(savedInstanceState);
+        setContentView(R.layout.activity_java_demo);
+        TextView tvCaption = (TextView) findViewById(R.id.tvCaption);
+        tvCaption.setText(R.string.java_demo_caption);
+
+        WebView wvSource = (WebView) findViewById(R.id.wvSource);
+        try {
+            viewSource(this, wvSource, "JavaDemoActivity.java");
+        } catch (IOException e) {
+            throw new RuntimeException(e);
+        }
+    }
+
+    private static final String ASSET_SOURCE_DIR = "source";
+    private static final String EXTRA_CSS =
+        "body { background-color: #eeeeee; font-size: 85%; }";
+
+    // Compare with the equivalent Python code in chaquopy/demo/ui_demo.py
+    private static void viewSource(Context context, WebView wv,
+                                   String filename) throws IOException {
+        InputStream stream = context.getAssets().open
+            (ASSET_SOURCE_DIR + "/" + filename);
+        BufferedReader reader =
+            new BufferedReader(new InputStreamReader(stream));
+        String text = "";
+        String line;
+        while ((line = reader.readLine()) != null) {
+            text += line + "\n";
+        }
+
+        Python py = Python.getInstance();
+        PyObject pygments = py.getModule("pygments");
+        PyObject formatters = py.getModule("pygments.formatters");
+        PyObject lexers = py.getModule("pygments.lexers");
+
+        PyObject formatter = formatters.callAttr("HtmlFormatter");
+        PyObject lexer = lexers.callAttr("get_lexer_for_filename", filename);
+        String body = pygments.callAttr("highlight", text, lexer, formatter)
+            .toString();
+
+        String html = String.format(
+            "<html><head><style>%s\n%s</style></head><body>%s</body></html>",
+            formatter.callAttr("get_style_defs"), EXTRA_CSS, body);
+        wv.loadData(Base64.encodeToString(html.getBytes("ASCII"),
+                                          Base64.DEFAULT),
+                    "text/html", "base64");
+    }
+}
diff --git a/app/app/src/main/java/com/chaquo/python/demo/JavaTestActivity.java b/app/app/src/main/java/com/chaquo/python/demo/JavaTestActivity.java
new file mode 100644
index 0000000000000000000000000000000000000000..fe6dd0ee8ce7d7f1f6ee428eeff89420ba40c7d7
--- /dev/null
+++ b/app/app/src/main/java/com/chaquo/python/demo/JavaTestActivity.java
@@ -0,0 +1,98 @@
+package com.chaquo.python.demo;
+
+import android.app.*;
+import com.chaquo.python.utils.*;
+import java.io.*;
+import org.junit.runner.*;
+import org.junit.runner.notification.*;
+
+public class JavaTestActivity extends ConsoleActivity {
+
+    @Override protected Class<? extends JavaTestTask> getTaskClass() {
+        return JavaTestTask.class;
+    }
+
+    // =============================================================================================
+
+    public static class JavaTestTask extends Task {
+
+        public JavaTestTask(Application app) {
+            super(app);
+        }
+
+        @Override public void run() {
+            JUnitCore juc = new JUnitCore();
+            juc.addListener(new Listener());
+            juc.run(com.chaquo.java.TestSuite.class);
+        }
+
+        private class Listener extends RunListener {
+            private ByteArrayOutputStream stdout = new ByteArrayOutputStream();
+            private ByteArrayOutputStream stderr = new ByteArrayOutputStream();
+
+            @Override
+            public void testRunStarted(Description description) throws Exception {
+                System.setOut(new PrintStream(stdout));
+                System.setErr(new PrintStream(stderr));
+            }
+
+            @Override
+            public void testStarted(Description description) throws Exception {
+                output(description.toString() + "\n");
+            }
+
+            @Override
+            public void testIgnored(Description description) throws Exception {
+                output("IGNORED\n");
+            }
+
+            @Override
+            public void testFinished(Description description) throws Exception {
+                output(stdout.toString());
+                stdout.reset();
+                outputError(stderr.toString());
+                stderr.reset();
+            }
+
+            @Override
+            public void testFailure(Failure failure) throws Exception {
+                String trace = failure.getTrace();
+                StringBuilder filteredTrace = new StringBuilder();
+                boolean filterOn = false;
+                int lineNum = 0;
+                for (String line : trace.split("\n")) {
+                    lineNum++;
+                    if (lineNum > 1 && line.matches(".*org.junit.*")) {
+                        filterOn = true;
+                    } else {
+                        if (filterOn) {
+                            filteredTrace.append("...\n");
+                            filterOn = false;
+                        }
+                        filteredTrace.append(line).append("\n");
+                    }
+                }
+                outputError(filteredTrace);
+            }
+
+            @Override
+            public void testAssumptionFailure(Failure failure) {
+                outputError("ASSUMPTION FAILED\n");
+            }
+
+            @Override
+            public void testRunFinished(Result result) throws Exception {
+                String message = String.format(
+                    "Ran %s tests in %.3f seconds (%s failed, %s ignored)\n",
+                    result.getRunCount(), result.getRunTime() / 1000.0,
+                    result.getFailureCount(), result.getIgnoreCount());
+                if (result.getFailureCount() > 0) {
+                    outputError(message);
+                } else {
+                    output(message);
+                }
+            }
+        }
+    }
+
+}
diff --git a/app/app/src/main/java/com/chaquo/python/demo/TestAndroidReflect.java b/app/app/src/main/java/com/chaquo/python/demo/TestAndroidReflect.java
new file mode 100644
index 0000000000000000000000000000000000000000..c5f4845210be6c7019ba3599608cc9cbb459e19a
--- /dev/null
+++ b/app/app/src/main/java/com/chaquo/python/demo/TestAndroidReflect.java
@@ -0,0 +1,21 @@
+package com.chaquo.python.demo;
+
+import android.view.textclassifier.*;
+
+@SuppressWarnings("unused")
+public class TestAndroidReflect {
+    public TextClassifier tcFieldPublic;
+    protected TextClassifier tcFieldProtected;
+
+    public TextClassifier tcMethodPublic() { return null; }
+    protected TextClassifier tcMethodProtected() { return null; }
+
+    public int iFieldPublic;
+    protected int iFieldProtected;
+
+    public int iMethodPublic() { return 0; }
+    protected int iMethodProtected() { return 0; }
+
+    @Override
+    protected void finalize() {}
+}
diff --git a/app/app/src/main/java/com/example/privatestorage/FirstFragment.kt b/app/app/src/main/java/com/example/privatestorage/FirstFragment.kt
deleted file mode 100644
index 66b938151831bae65b17537349473d3df9f0eb15..0000000000000000000000000000000000000000
--- a/app/app/src/main/java/com/example/privatestorage/FirstFragment.kt
+++ /dev/null
@@ -1,44 +0,0 @@
-package com.example.privatestorage
-
-import android.os.Bundle
-import androidx.fragment.app.Fragment
-import android.view.LayoutInflater
-import android.view.View
-import android.view.ViewGroup
-import androidx.navigation.fragment.findNavController
-import com.example.privatestorage.databinding.FragmentFirstBinding
-
-/**
- * A simple [Fragment] subclass as the default destination in the navigation.
- */
-class FirstFragment : Fragment() {
-
-    private var _binding: FragmentFirstBinding? = null
-
-    // This property is only valid between onCreateView and
-    // onDestroyView.
-    private val binding get() = _binding!!
-
-    override fun onCreateView(
-            inflater: LayoutInflater, container: ViewGroup?,
-            savedInstanceState: Bundle?
-    ): View? {
-
-        _binding = FragmentFirstBinding.inflate(inflater, container, false)
-        return binding.root
-
-    }
-
-    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
-        super.onViewCreated(view, savedInstanceState)
-
-        binding.buttonFirst.setOnClickListener {
-            findNavController().navigate(R.id.action_FirstFragment_to_SecondFragment)
-        }
-    }
-
-    override fun onDestroyView() {
-        super.onDestroyView()
-        _binding = null
-    }
-}
\ No newline at end of file
diff --git a/app/app/src/main/java/com/example/privatestorage/MainActivity.kt b/app/app/src/main/java/com/example/privatestorage/MainActivity.kt
deleted file mode 100644
index 96af4747569c11190f939918289dd0125d9f94e0..0000000000000000000000000000000000000000
--- a/app/app/src/main/java/com/example/privatestorage/MainActivity.kt
+++ /dev/null
@@ -1,58 +0,0 @@
-package com.example.privatestorage
-
-import android.os.Bundle
-import com.google.android.material.snackbar.Snackbar
-import androidx.appcompat.app.AppCompatActivity
-import androidx.navigation.findNavController
-import androidx.navigation.ui.AppBarConfiguration
-import androidx.navigation.ui.navigateUp
-import androidx.navigation.ui.setupActionBarWithNavController
-import android.view.Menu
-import android.view.MenuItem
-import com.example.privatestorage.databinding.ActivityMainBinding
-
-class MainActivity : AppCompatActivity() {
-
-    private lateinit var appBarConfiguration: AppBarConfiguration
-    private lateinit var binding: ActivityMainBinding
-
-    override fun onCreate(savedInstanceState: Bundle?) {
-        super.onCreate(savedInstanceState)
-
-        binding = ActivityMainBinding.inflate(layoutInflater)
-        setContentView(binding.root)
-
-        setSupportActionBar(binding.toolbar)
-
-        val navController = findNavController(R.id.nav_host_fragment_content_main)
-        appBarConfiguration = AppBarConfiguration(navController.graph)
-        setupActionBarWithNavController(navController, appBarConfiguration)
-
-        binding.fab.setOnClickListener { view ->
-            Snackbar.make(view, "Replace with your own action", Snackbar.LENGTH_LONG)
-                    .setAction("Action", null).show()
-        }
-    }
-
-    override fun onCreateOptionsMenu(menu: Menu): Boolean {
-        // Inflate the menu; this adds items to the action bar if it is present.
-        menuInflater.inflate(R.menu.menu_main, menu)
-        return true
-    }
-
-    override fun onOptionsItemSelected(item: MenuItem): Boolean {
-        // Handle action bar item clicks here. The action bar will
-        // automatically handle clicks on the Home/Up button, so long
-        // as you specify a parent activity in AndroidManifest.xml.
-        return when (item.itemId) {
-            R.id.action_settings -> true
-            else -> super.onOptionsItemSelected(item)
-        }
-    }
-
-    override fun onSupportNavigateUp(): Boolean {
-        val navController = findNavController(R.id.nav_host_fragment_content_main)
-        return navController.navigateUp(appBarConfiguration)
-                || super.onSupportNavigateUp()
-    }
-}
\ No newline at end of file
diff --git a/app/app/src/main/java/com/example/privatestorage/SecondFragment.kt b/app/app/src/main/java/com/example/privatestorage/SecondFragment.kt
deleted file mode 100644
index ff498cc7bef7d1ccc3bb663e00b5dd82649602a5..0000000000000000000000000000000000000000
--- a/app/app/src/main/java/com/example/privatestorage/SecondFragment.kt
+++ /dev/null
@@ -1,44 +0,0 @@
-package com.example.privatestorage
-
-import android.os.Bundle
-import androidx.fragment.app.Fragment
-import android.view.LayoutInflater
-import android.view.View
-import android.view.ViewGroup
-import androidx.navigation.fragment.findNavController
-import com.example.privatestorage.databinding.FragmentSecondBinding
-
-/**
- * A simple [Fragment] subclass as the second destination in the navigation.
- */
-class SecondFragment : Fragment() {
-
-    private var _binding: FragmentSecondBinding? = null
-
-    // This property is only valid between onCreateView and
-    // onDestroyView.
-    private val binding get() = _binding!!
-
-    override fun onCreateView(
-            inflater: LayoutInflater, container: ViewGroup?,
-            savedInstanceState: Bundle?
-    ): View? {
-
-        _binding = FragmentSecondBinding.inflate(inflater, container, false)
-        return binding.root
-
-    }
-
-    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
-        super.onViewCreated(view, savedInstanceState)
-
-        binding.buttonSecond.setOnClickListener {
-            findNavController().navigate(R.id.action_SecondFragment_to_FirstFragment)
-        }
-    }
-
-    override fun onDestroyView() {
-        super.onDestroyView()
-        _binding = null
-    }
-}
\ No newline at end of file
diff --git a/app/app/src/main/python/chaquopy/__init__.py b/app/app/src/main/python/chaquopy/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/app/app/src/main/python/chaquopy/demo/__init__.py b/app/app/src/main/python/chaquopy/demo/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/app/app/src/main/python/chaquopy/demo/ui_demo.py b/app/app/src/main/python/chaquopy/demo/ui_demo.py
new file mode 100644
index 0000000000000000000000000000000000000000..a9910ea50e17b9189a69872b43c321c8b7c50a09
--- /dev/null
+++ b/app/app/src/main/python/chaquopy/demo/ui_demo.py
@@ -0,0 +1,136 @@
+from java import dynamic_proxy, jboolean, jvoid, Override, static_proxy
+
+from android.app import AlertDialog
+from android.content import Context, DialogInterface
+from android.graphics.drawable import ColorDrawable
+from android.os import Bundle
+from androidx.appcompat.app import AppCompatActivity
+from androidx.fragment.app import DialogFragment
+from androidx.preference import Preference, PreferenceFragmentCompat
+from android.view import Menu, MenuItem, View
+from java.lang import String
+
+from com.chaquo.python.demo import R
+
+
+class UIDemoActivity(static_proxy(AppCompatActivity)):
+    @Override(jvoid, [Bundle])
+    def onCreate(self, state):
+        AppCompatActivity.onCreate(self, state)
+        if state is None:
+            state = Bundle()
+        self.setContentView(R.layout.activity_menu)
+        self.findViewById(R.id.tvCaption).setText(R.string.demo_caption)
+
+        self.title_drawable = ColorDrawable()
+        self.getSupportActionBar().setBackgroundDrawable(self.title_drawable)
+        self.title_drawable.setColor(
+            state.getInt("title_color", self.getResources().getColor(R.color.blue)))
+
+        self.wvSource = self.findViewById(R.id.wvSource)
+        view_source(self, self.wvSource, "ui_demo.py")
+        self.wvSource.setVisibility(state.getInt("source_visibility", View.GONE))
+
+        self.getSupportFragmentManager().beginTransaction()\
+            .replace(R.id.flMenu, MenuFragment()).commit()
+
+    @Override(jvoid, [Bundle])
+    def onSaveInstanceState(self, state):
+        state.putInt("source_visibility", self.wvSource.getVisibility())
+        state.putInt("title_color", self.title_drawable.getColor())
+
+    @Override(jboolean, [Menu])
+    def onCreateOptionsMenu(self, menu):
+        self.getMenuInflater().inflate(R.menu.view_source, menu)
+        return True
+
+    @Override(jboolean, [MenuItem])
+    def onOptionsItemSelected(self, item):
+        id = item.getItemId()
+        if id == R.id.menu_source:
+            vis = self.wvSource.getVisibility()
+            new_vis = View.VISIBLE if (vis == View.GONE) else View.GONE
+            self.wvSource.setVisibility(new_vis)
+            return True
+        else:
+            return False
+
+
+class MenuFragment(static_proxy(PreferenceFragmentCompat)):
+    @Override(jvoid, [Bundle, String])
+    def onCreatePreferences(self, state, rootKey):
+        self.addPreferencesFromResource(R.xml.activity_ui_demo)
+
+        from android.media import AudioManager, SoundPool
+        self.sound_pool = SoundPool(1, AudioManager.STREAM_MUSIC, 0)
+        self.sound_id = self.sound_pool.load(self.getActivity(), R.raw.sound, 1)
+
+    @Override(jboolean, [Preference])
+    def onPreferenceTreeClick(self, pref):
+        method = getattr(self, pref.getKey())
+        if method:
+            method(self.getActivity())
+            return True
+        else:
+            return False
+
+    def demo_dialog(self, activity):
+        ColorDialog().show(self.getFragmentManager(), "color")
+
+    def demo_toast(self, activity):
+        from android.widget import Toast
+        Toast.makeText(activity, R.string.demo_toast_text,
+                       Toast.LENGTH_SHORT).show()
+
+    def demo_sound(self, activity):
+        self.sound_pool.play(self.sound_id, 1, 1, 0, 0, 1)
+
+    def demo_vibrate(self, activity):
+        activity.getSystemService(Context.VIBRATOR_SERVICE)\
+            .vibrate(200)
+
+
+class ColorDialog(static_proxy(DialogFragment)):
+    @Override(AlertDialog, [Bundle])
+    def onCreateDialog(self, state):
+        activity = self.getActivity()
+        builder = AlertDialog.Builder(activity)
+        builder.setTitle(R.string.demo_dialog_title)
+        builder.setMessage(R.string.demo_dialog_text)
+
+        class Listener(dynamic_proxy(DialogInterface.OnClickListener)):
+            def __init__(self, color_res):
+                super(Listener, self).__init__()
+                self.color = activity.getResources().getColor(color_res)
+
+            def onClick(self, dialog, which):
+                activity.title_drawable.setColor(self.color)
+
+        builder.setNegativeButton(R.string.red, Listener(R.color.red))
+        builder.setNeutralButton(R.string.green, Listener(R.color.green))
+        builder.setPositiveButton(R.string.blue, Listener(R.color.blue))
+        return builder.create()
+
+
+ASSET_SOURCE_DIR = "source"
+EXTRA_CSS = "body { background-color: #eeeeee; font-size: 85%; }"
+
+# Compare with the equivalent Java code in JavaDemoActivity.java
+def view_source(context, web_view, filename):
+    from base64 import b64encode
+    from os.path import join
+    from pygments import highlight
+    from pygments.formatters import HtmlFormatter
+    from pygments.lexers import get_lexer_for_filename
+
+    from java.io import BufferedReader, InputStreamReader
+
+    stream = context.getAssets().open(join(ASSET_SOURCE_DIR, filename))
+    reader = BufferedReader(InputStreamReader(stream))
+    text = "\n".join(iter(reader.readLine, None))
+
+    formatter = HtmlFormatter()
+    body = highlight(text, get_lexer_for_filename(filename), formatter)
+    html = ("<html><head><style>{}\n{}</style></head><body>{}</body></html>"
+            .format(formatter.get_style_defs(), EXTRA_CSS, body)).encode()
+    web_view.loadData(b64encode(html).decode(), "text/html", "base64")
diff --git a/app/app/src/main/python/main.py b/app/app/src/main/python/main.py
deleted file mode 100644
index 30d202f5caad9b53666bda5e0cfd4c8e92f39ffa..0000000000000000000000000000000000000000
--- a/app/app/src/main/python/main.py
+++ /dev/null
@@ -1,14 +0,0 @@
-from kivy.app import App
-from kivy.uix.label import Label
-
-class MainApp(App):
-    def build(self):
-        label = Label(text='Hello from Kivy',
-                      size_hint=(.5, .5),
-                      pos_hint={'center_x': .5, 'center_y': .5})
-
-        return label
-
-if __name__ == '__main__':
-    app = MainApp()
-    app.run()
diff --git a/app/app/src/main/res/drawable-v24/ic_launcher_foreground.xml b/app/app/src/main/res/drawable-v24/ic_launcher_foreground.xml
deleted file mode 100644
index 2b068d11462a4b96669193de13a711a3a36220a0..0000000000000000000000000000000000000000
--- a/app/app/src/main/res/drawable-v24/ic_launcher_foreground.xml
+++ /dev/null
@@ -1,30 +0,0 @@
-<vector xmlns:android="http://schemas.android.com/apk/res/android"
-    xmlns:aapt="http://schemas.android.com/aapt"
-    android:width="108dp"
-    android:height="108dp"
-    android:viewportWidth="108"
-    android:viewportHeight="108">
-    <path android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z">
-        <aapt:attr name="android:fillColor">
-            <gradient
-                android:endX="85.84757"
-                android:endY="92.4963"
-                android:startX="42.9492"
-                android:startY="49.59793"
-                android:type="linear">
-                <item
-                    android:color="#44000000"
-                    android:offset="0.0" />
-                <item
-                    android:color="#00000000"
-                    android:offset="1.0" />
-            </gradient>
-        </aapt:attr>
-    </path>
-    <path
-        android:fillColor="#FFFFFF"
-        android:fillType="nonZero"
-        android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z"
-        android:strokeWidth="1"
-        android:strokeColor="#00000000" />
-</vector>
\ No newline at end of file
diff --git a/app/app/src/main/res/drawable/ic_launcher_background.xml b/app/app/src/main/res/drawable/ic_launcher_background.xml
deleted file mode 100644
index 07d5da9cbf141911847041df5d7b87f0dd5ef9d4..0000000000000000000000000000000000000000
--- a/app/app/src/main/res/drawable/ic_launcher_background.xml
+++ /dev/null
@@ -1,170 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?>
-<vector xmlns:android="http://schemas.android.com/apk/res/android"
-    android:width="108dp"
-    android:height="108dp"
-    android:viewportWidth="108"
-    android:viewportHeight="108">
-    <path
-        android:fillColor="#3DDC84"
-        android:pathData="M0,0h108v108h-108z" />
-    <path
-        android:fillColor="#00000000"
-        android:pathData="M9,0L9,108"
-        android:strokeWidth="0.8"
-        android:strokeColor="#33FFFFFF" />
-    <path
-        android:fillColor="#00000000"
-        android:pathData="M19,0L19,108"
-        android:strokeWidth="0.8"
-        android:strokeColor="#33FFFFFF" />
-    <path
-        android:fillColor="#00000000"
-        android:pathData="M29,0L29,108"
-        android:strokeWidth="0.8"
-        android:strokeColor="#33FFFFFF" />
-    <path
-        android:fillColor="#00000000"
-        android:pathData="M39,0L39,108"
-        android:strokeWidth="0.8"
-        android:strokeColor="#33FFFFFF" />
-    <path
-        android:fillColor="#00000000"
-        android:pathData="M49,0L49,108"
-        android:strokeWidth="0.8"
-        android:strokeColor="#33FFFFFF" />
-    <path
-        android:fillColor="#00000000"
-        android:pathData="M59,0L59,108"
-        android:strokeWidth="0.8"
-        android:strokeColor="#33FFFFFF" />
-    <path
-        android:fillColor="#00000000"
-        android:pathData="M69,0L69,108"
-        android:strokeWidth="0.8"
-        android:strokeColor="#33FFFFFF" />
-    <path
-        android:fillColor="#00000000"
-        android:pathData="M79,0L79,108"
-        android:strokeWidth="0.8"
-        android:strokeColor="#33FFFFFF" />
-    <path
-        android:fillColor="#00000000"
-        android:pathData="M89,0L89,108"
-        android:strokeWidth="0.8"
-        android:strokeColor="#33FFFFFF" />
-    <path
-        android:fillColor="#00000000"
-        android:pathData="M99,0L99,108"
-        android:strokeWidth="0.8"
-        android:strokeColor="#33FFFFFF" />
-    <path
-        android:fillColor="#00000000"
-        android:pathData="M0,9L108,9"
-        android:strokeWidth="0.8"
-        android:strokeColor="#33FFFFFF" />
-    <path
-        android:fillColor="#00000000"
-        android:pathData="M0,19L108,19"
-        android:strokeWidth="0.8"
-        android:strokeColor="#33FFFFFF" />
-    <path
-        android:fillColor="#00000000"
-        android:pathData="M0,29L108,29"
-        android:strokeWidth="0.8"
-        android:strokeColor="#33FFFFFF" />
-    <path
-        android:fillColor="#00000000"
-        android:pathData="M0,39L108,39"
-        android:strokeWidth="0.8"
-        android:strokeColor="#33FFFFFF" />
-    <path
-        android:fillColor="#00000000"
-        android:pathData="M0,49L108,49"
-        android:strokeWidth="0.8"
-        android:strokeColor="#33FFFFFF" />
-    <path
-        android:fillColor="#00000000"
-        android:pathData="M0,59L108,59"
-        android:strokeWidth="0.8"
-        android:strokeColor="#33FFFFFF" />
-    <path
-        android:fillColor="#00000000"
-        android:pathData="M0,69L108,69"
-        android:strokeWidth="0.8"
-        android:strokeColor="#33FFFFFF" />
-    <path
-        android:fillColor="#00000000"
-        android:pathData="M0,79L108,79"
-        android:strokeWidth="0.8"
-        android:strokeColor="#33FFFFFF" />
-    <path
-        android:fillColor="#00000000"
-        android:pathData="M0,89L108,89"
-        android:strokeWidth="0.8"
-        android:strokeColor="#33FFFFFF" />
-    <path
-        android:fillColor="#00000000"
-        android:pathData="M0,99L108,99"
-        android:strokeWidth="0.8"
-        android:strokeColor="#33FFFFFF" />
-    <path
-        android:fillColor="#00000000"
-        android:pathData="M19,29L89,29"
-        android:strokeWidth="0.8"
-        android:strokeColor="#33FFFFFF" />
-    <path
-        android:fillColor="#00000000"
-        android:pathData="M19,39L89,39"
-        android:strokeWidth="0.8"
-        android:strokeColor="#33FFFFFF" />
-    <path
-        android:fillColor="#00000000"
-        android:pathData="M19,49L89,49"
-        android:strokeWidth="0.8"
-        android:strokeColor="#33FFFFFF" />
-    <path
-        android:fillColor="#00000000"
-        android:pathData="M19,59L89,59"
-        android:strokeWidth="0.8"
-        android:strokeColor="#33FFFFFF" />
-    <path
-        android:fillColor="#00000000"
-        android:pathData="M19,69L89,69"
-        android:strokeWidth="0.8"
-        android:strokeColor="#33FFFFFF" />
-    <path
-        android:fillColor="#00000000"
-        android:pathData="M19,79L89,79"
-        android:strokeWidth="0.8"
-        android:strokeColor="#33FFFFFF" />
-    <path
-        android:fillColor="#00000000"
-        android:pathData="M29,19L29,89"
-        android:strokeWidth="0.8"
-        android:strokeColor="#33FFFFFF" />
-    <path
-        android:fillColor="#00000000"
-        android:pathData="M39,19L39,89"
-        android:strokeWidth="0.8"
-        android:strokeColor="#33FFFFFF" />
-    <path
-        android:fillColor="#00000000"
-        android:pathData="M49,19L49,89"
-        android:strokeWidth="0.8"
-        android:strokeColor="#33FFFFFF" />
-    <path
-        android:fillColor="#00000000"
-        android:pathData="M59,19L59,89"
-        android:strokeWidth="0.8"
-        android:strokeColor="#33FFFFFF" />
-    <path
-        android:fillColor="#00000000"
-        android:pathData="M69,19L69,89"
-        android:strokeWidth="0.8"
-        android:strokeColor="#33FFFFFF" />
-    <path
-        android:fillColor="#00000000"
-        android:pathData="M79,19L79,89"
-        android:strokeWidth="0.8"
-        android:strokeColor="#33FFFFFF" />
-</vector>
diff --git a/app/app/src/main/res/layout/activity_java_demo.xml b/app/app/src/main/res/layout/activity_java_demo.xml
new file mode 100644
index 0000000000000000000000000000000000000000..303916bd6ae268d7203974e8dd82c0d6204ec418
--- /dev/null
+++ b/app/app/src/main/res/layout/activity_java_demo.xml
@@ -0,0 +1,41 @@
+<?xml version="1.0" encoding="utf-8"?>
+<androidx.constraintlayout.widget.ConstraintLayout
+    xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:app="http://schemas.android.com/apk/res-auto"
+    xmlns:tools="http://schemas.android.com/tools"
+    android:layout_width="match_parent"
+    android:layout_height="match_parent"
+    tools:context="com.chaquo.python.demo.JavaDemoActivity">
+
+    <TextView
+        android:id="@+id/tvCaption"
+        android:layout_width="0dp"
+        android:layout_height="wrap_content"
+        android:layout_marginEnd="8dp"
+        android:layout_marginLeft="8dp"
+        android:layout_marginRight="8dp"
+        android:layout_marginStart="8dp"
+        android:layout_marginTop="8dp"
+        app:layout_constraintLeft_toLeftOf="parent"
+        app:layout_constraintRight_toRightOf="parent"
+        app:layout_constraintTop_toTopOf="parent"
+        app:layout_constraintHorizontal_bias="0.0"/>
+
+    <WebView
+        android:id="@+id/wvSource"
+        android:layout_width="0dp"
+        android:layout_height="0dp"
+        android:layout_marginBottom="0dp"
+        android:layout_marginEnd="8dp"
+        android:layout_marginLeft="8dp"
+        android:layout_marginRight="8dp"
+        android:layout_marginStart="8dp"
+        android:layout_marginTop="8dp"
+        android:visibility="visible"
+        app:layout_constraintBottom_toBottomOf="parent"
+        app:layout_constraintLeft_toLeftOf="parent"
+        app:layout_constraintRight_toRightOf="parent"
+        app:layout_constraintTop_toBottomOf="@+id/tvCaption"
+        app:layout_constraintVertical_bias="0.0"/>
+
+</androidx.constraintlayout.widget.ConstraintLayout>
diff --git a/app/app/src/main/res/layout/activity_main.xml b/app/app/src/main/res/layout/activity_main.xml
deleted file mode 100644
index 11daef10356ea126f78bef34b21eb396f93983f6..0000000000000000000000000000000000000000
--- a/app/app/src/main/res/layout/activity_main.xml
+++ /dev/null
@@ -1,34 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?>
-<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
-    xmlns:app="http://schemas.android.com/apk/res-auto"
-    xmlns:tools="http://schemas.android.com/tools"
-    android:layout_width="match_parent"
-    android:layout_height="match_parent"
-    tools:context=".MainActivity">
-
-    <com.google.android.material.appbar.AppBarLayout
-        android:layout_width="match_parent"
-        android:layout_height="wrap_content"
-        android:theme="@style/Theme.PrivateStorage.AppBarOverlay">
-
-        <androidx.appcompat.widget.Toolbar
-            android:id="@+id/toolbar"
-            android:layout_width="match_parent"
-            android:layout_height="?attr/actionBarSize"
-            android:background="?attr/colorPrimary"
-            app:popupTheme="@style/Theme.PrivateStorage.PopupOverlay" />
-
-    </com.google.android.material.appbar.AppBarLayout>
-
-    <include layout="@layout/content_main" />
-
-    <com.google.android.material.floatingactionbutton.FloatingActionButton
-        android:id="@+id/fab"
-        android:layout_width="wrap_content"
-        android:layout_height="wrap_content"
-        android:layout_gravity="bottom|end"
-        android:layout_marginEnd="@dimen/fab_margin"
-        android:layout_marginBottom="16dp"
-        app:srcCompat="@android:drawable/ic_dialog_email" />
-
-</androidx.coordinatorlayout.widget.CoordinatorLayout>
\ No newline at end of file
diff --git a/app/app/src/main/res/layout/content_main.xml b/app/app/src/main/res/layout/content_main.xml
deleted file mode 100644
index e416e1c18d53ed5397bc0e696dcec55833cc91e5..0000000000000000000000000000000000000000
--- a/app/app/src/main/res/layout/content_main.xml
+++ /dev/null
@@ -1,19 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?>
-<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
-    xmlns:app="http://schemas.android.com/apk/res-auto"
-    android:layout_width="match_parent"
-    android:layout_height="match_parent"
-    app:layout_behavior="@string/appbar_scrolling_view_behavior">
-
-    <fragment
-        android:id="@+id/nav_host_fragment_content_main"
-        android:name="androidx.navigation.fragment.NavHostFragment"
-        android:layout_width="0dp"
-        android:layout_height="0dp"
-        app:defaultNavHost="true"
-        app:layout_constraintBottom_toBottomOf="parent"
-        app:layout_constraintEnd_toEndOf="parent"
-        app:layout_constraintStart_toStartOf="parent"
-        app:layout_constraintTop_toTopOf="parent"
-        app:navGraph="@navigation/nav_graph" />
-</androidx.constraintlayout.widget.ConstraintLayout>
\ No newline at end of file
diff --git a/app/app/src/main/res/layout/fragment_first.xml b/app/app/src/main/res/layout/fragment_first.xml
deleted file mode 100644
index fb44a3d9176c7d931719255605c270c686734e3e..0000000000000000000000000000000000000000
--- a/app/app/src/main/res/layout/fragment_first.xml
+++ /dev/null
@@ -1,28 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?>
-<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
-    xmlns:app="http://schemas.android.com/apk/res-auto"
-    xmlns:tools="http://schemas.android.com/tools"
-    android:layout_width="match_parent"
-    android:layout_height="match_parent"
-    tools:context=".FirstFragment">
-
-    <TextView
-        android:id="@+id/textview_first"
-        android:layout_width="wrap_content"
-        android:layout_height="wrap_content"
-        android:text="@string/hello_first_fragment"
-        app:layout_constraintBottom_toTopOf="@id/button_first"
-        app:layout_constraintEnd_toEndOf="parent"
-        app:layout_constraintStart_toStartOf="parent"
-        app:layout_constraintTop_toTopOf="parent" />
-
-    <Button
-        android:id="@+id/button_first"
-        android:layout_width="wrap_content"
-        android:layout_height="wrap_content"
-        android:text="@string/next"
-        app:layout_constraintBottom_toBottomOf="parent"
-        app:layout_constraintEnd_toEndOf="parent"
-        app:layout_constraintStart_toStartOf="parent"
-        app:layout_constraintTop_toBottomOf="@id/textview_first" />
-</androidx.constraintlayout.widget.ConstraintLayout>
\ No newline at end of file
diff --git a/app/app/src/main/res/layout/fragment_second.xml b/app/app/src/main/res/layout/fragment_second.xml
deleted file mode 100644
index bd9052422ed284cd786132c85decbb447d84a7e5..0000000000000000000000000000000000000000
--- a/app/app/src/main/res/layout/fragment_second.xml
+++ /dev/null
@@ -1,27 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?>
-<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
-    xmlns:app="http://schemas.android.com/apk/res-auto"
-    xmlns:tools="http://schemas.android.com/tools"
-    android:layout_width="match_parent"
-    android:layout_height="match_parent"
-    tools:context=".SecondFragment">
-
-    <TextView
-        android:id="@+id/textview_second"
-        android:layout_width="wrap_content"
-        android:layout_height="wrap_content"
-        app:layout_constraintBottom_toTopOf="@id/button_second"
-        app:layout_constraintEnd_toEndOf="parent"
-        app:layout_constraintStart_toStartOf="parent"
-        app:layout_constraintTop_toTopOf="parent" />
-
-    <Button
-        android:id="@+id/button_second"
-        android:layout_width="wrap_content"
-        android:layout_height="wrap_content"
-        android:text="@string/previous"
-        app:layout_constraintBottom_toBottomOf="parent"
-        app:layout_constraintEnd_toEndOf="parent"
-        app:layout_constraintStart_toStartOf="parent"
-        app:layout_constraintTop_toBottomOf="@id/textview_second" />
-</androidx.constraintlayout.widget.ConstraintLayout>
\ No newline at end of file
diff --git a/app/app/src/main/res/menu/menu_main.xml b/app/app/src/main/res/menu/menu_main.xml
deleted file mode 100644
index 33462d365a175a690910dc9afedb7389a39ea810..0000000000000000000000000000000000000000
--- a/app/app/src/main/res/menu/menu_main.xml
+++ /dev/null
@@ -1,10 +0,0 @@
-<menu xmlns:android="http://schemas.android.com/apk/res/android"
-    xmlns:app="http://schemas.android.com/apk/res-auto"
-    xmlns:tools="http://schemas.android.com/tools"
-    tools:context="com.example.privatestorage.MainActivity">
-    <item
-        android:id="@+id/action_settings"
-        android:orderInCategory="100"
-        android:title="@string/action_settings"
-        app:showAsAction="never" />
-</menu>
\ No newline at end of file
diff --git a/app/app/src/main/res/menu/view_source.xml b/app/app/src/main/res/menu/view_source.xml
new file mode 100644
index 0000000000000000000000000000000000000000..1e34fee847f7dfd9be56b54586e6ea40554bc407
--- /dev/null
+++ b/app/app/src/main/res/menu/view_source.xml
@@ -0,0 +1,9 @@
+<?xml version="1.0" encoding="utf-8"?>
+<menu xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:app="http://schemas.android.com/apk/res-auto">
+
+    <item android:id="@+id/menu_source"
+        android:title="@string/view_source"
+        app:showAsAction="always|withText"/>
+
+</menu>
\ No newline at end of file
diff --git a/app/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
deleted file mode 100644
index eca70cfe52eac1ba66ba280a68ca7be8fcf88a16..0000000000000000000000000000000000000000
--- a/app/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
+++ /dev/null
@@ -1,5 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?>
-<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
-    <background android:drawable="@drawable/ic_launcher_background" />
-    <foreground android:drawable="@drawable/ic_launcher_foreground" />
-</adaptive-icon>
\ No newline at end of file
diff --git a/app/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
deleted file mode 100644
index eca70cfe52eac1ba66ba280a68ca7be8fcf88a16..0000000000000000000000000000000000000000
--- a/app/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
+++ /dev/null
@@ -1,5 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?>
-<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
-    <background android:drawable="@drawable/ic_launcher_background" />
-    <foreground android:drawable="@drawable/ic_launcher_foreground" />
-</adaptive-icon>
\ No newline at end of file
diff --git a/app/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/app/app/src/main/res/mipmap-hdpi/ic_launcher.webp
deleted file mode 100644
index c209e78ecd372343283f4157dcfd918ec5165bb3..0000000000000000000000000000000000000000
Binary files a/app/app/src/main/res/mipmap-hdpi/ic_launcher.webp and /dev/null differ
diff --git a/app/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp b/app/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp
deleted file mode 100644
index b2dfe3d1ba5cf3ee31b3ecc1ced89044a1f3b7a9..0000000000000000000000000000000000000000
Binary files a/app/app/src/main/res/mipmap-hdpi/ic_launcher_round.webp and /dev/null differ
diff --git a/app/app/src/main/res/mipmap-mdpi/ic_launcher.webp b/app/app/src/main/res/mipmap-mdpi/ic_launcher.webp
deleted file mode 100644
index 4f0f1d64e58ba64d180ce43ee13bf9a17835fbca..0000000000000000000000000000000000000000
Binary files a/app/app/src/main/res/mipmap-mdpi/ic_launcher.webp and /dev/null differ
diff --git a/app/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/app/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp
deleted file mode 100644
index 62b611da081676d42f6c3f78a2c91e7bcedddedb..0000000000000000000000000000000000000000
Binary files a/app/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp and /dev/null differ
diff --git a/app/app/src/main/res/mipmap-xhdpi/ic_launcher.webp b/app/app/src/main/res/mipmap-xhdpi/ic_launcher.webp
deleted file mode 100644
index 948a3070fe34c611c42c0d3ad3013a0dce358be0..0000000000000000000000000000000000000000
Binary files a/app/app/src/main/res/mipmap-xhdpi/ic_launcher.webp and /dev/null differ
diff --git a/app/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/app/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp
deleted file mode 100644
index 1b9a6956b3acdc11f40ce2bb3f6efbd845cc243f..0000000000000000000000000000000000000000
Binary files a/app/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp and /dev/null differ
diff --git a/app/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/app/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp
deleted file mode 100644
index 28d4b77f9f036a47549d47db79c16788749dca10..0000000000000000000000000000000000000000
Binary files a/app/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp and /dev/null differ
diff --git a/app/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/app/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp
deleted file mode 100644
index 9287f5083623b375139afb391af71cc533a7dd37..0000000000000000000000000000000000000000
Binary files a/app/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp and /dev/null differ
diff --git a/app/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp b/app/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp
deleted file mode 100644
index aa7d6427e6fa1074b79ccd52ef67ac15c5637e85..0000000000000000000000000000000000000000
Binary files a/app/app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp and /dev/null differ
diff --git a/app/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/app/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp
deleted file mode 100644
index 9126ae37cbc3587421d6889eadd1d91fbf1994d4..0000000000000000000000000000000000000000
Binary files a/app/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp and /dev/null differ
diff --git a/app/app/src/main/res/navigation/nav_graph.xml b/app/app/src/main/res/navigation/nav_graph.xml
deleted file mode 100644
index b698442f5fa8c0ccf12fa668b2d3efdeaf2b01ce..0000000000000000000000000000000000000000
--- a/app/app/src/main/res/navigation/nav_graph.xml
+++ /dev/null
@@ -1,28 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?>
-<navigation xmlns:android="http://schemas.android.com/apk/res/android"
-    xmlns:app="http://schemas.android.com/apk/res-auto"
-    xmlns:tools="http://schemas.android.com/tools"
-    android:id="@+id/nav_graph"
-    app:startDestination="@id/FirstFragment">
-
-    <fragment
-        android:id="@+id/FirstFragment"
-        android:name="com.example.privatestorage.FirstFragment"
-        android:label="@string/first_fragment_label"
-        tools:layout="@layout/fragment_first">
-
-        <action
-            android:id="@+id/action_FirstFragment_to_SecondFragment"
-            app:destination="@id/SecondFragment" />
-    </fragment>
-    <fragment
-        android:id="@+id/SecondFragment"
-        android:name="com.example.privatestorage.SecondFragment"
-        android:label="@string/second_fragment_label"
-        tools:layout="@layout/fragment_second">
-
-        <action
-            android:id="@+id/action_SecondFragment_to_FirstFragment"
-            app:destination="@id/FirstFragment" />
-    </fragment>
-</navigation>
\ No newline at end of file
diff --git a/app/app/src/main/res/raw/sound.ogg b/app/app/src/main/res/raw/sound.ogg
new file mode 100644
index 0000000000000000000000000000000000000000..c9b030f06b1e306700d2269c9313f229eeac4cfe
Binary files /dev/null and b/app/app/src/main/res/raw/sound.ogg differ
diff --git a/app/app/src/main/res/values-land/dimens.xml b/app/app/src/main/res/values-land/dimens.xml
deleted file mode 100644
index 22d7f004329e4ff0529a55f37011a5ade468a19b..0000000000000000000000000000000000000000
--- a/app/app/src/main/res/values-land/dimens.xml
+++ /dev/null
@@ -1,3 +0,0 @@
-<resources>
-    <dimen name="fab_margin">48dp</dimen>
-</resources>
\ No newline at end of file
diff --git a/app/app/src/main/res/values-night/themes.xml b/app/app/src/main/res/values-night/themes.xml
deleted file mode 100644
index f06919cf0182145ab69bdd4f6ad47f5e357013a1..0000000000000000000000000000000000000000
--- a/app/app/src/main/res/values-night/themes.xml
+++ /dev/null
@@ -1,16 +0,0 @@
-<resources xmlns:tools="http://schemas.android.com/tools">
-    <!-- Base application theme. -->
-    <style name="Theme.PrivateStorage" parent="Theme.MaterialComponents.DayNight.DarkActionBar">
-        <!-- Primary brand color. -->
-        <item name="colorPrimary">@color/purple_200</item>
-        <item name="colorPrimaryVariant">@color/purple_700</item>
-        <item name="colorOnPrimary">@color/black</item>
-        <!-- Secondary brand color. -->
-        <item name="colorSecondary">@color/teal_200</item>
-        <item name="colorSecondaryVariant">@color/teal_200</item>
-        <item name="colorOnSecondary">@color/black</item>
-        <!-- Status bar color. -->
-        <item name="android:statusBarColor" tools:targetApi="l">?attr/colorPrimaryVariant</item>
-        <!-- Customize your theme here. -->
-    </style>
-</resources>
\ No newline at end of file
diff --git a/app/app/src/main/res/values-w1240dp/dimens.xml b/app/app/src/main/res/values-w1240dp/dimens.xml
deleted file mode 100644
index d73f4a359b5bbcb8fc1b5508036cee64621c0fc3..0000000000000000000000000000000000000000
--- a/app/app/src/main/res/values-w1240dp/dimens.xml
+++ /dev/null
@@ -1,3 +0,0 @@
-<resources>
-    <dimen name="fab_margin">200dp</dimen>
-</resources>
\ No newline at end of file
diff --git a/app/app/src/main/res/values-w600dp/dimens.xml b/app/app/src/main/res/values-w600dp/dimens.xml
deleted file mode 100644
index 22d7f004329e4ff0529a55f37011a5ade468a19b..0000000000000000000000000000000000000000
--- a/app/app/src/main/res/values-w600dp/dimens.xml
+++ /dev/null
@@ -1,3 +0,0 @@
-<resources>
-    <dimen name="fab_margin">48dp</dimen>
-</resources>
\ No newline at end of file
diff --git a/app/app/src/main/res/values/colors.xml b/app/app/src/main/res/values/colors.xml
index f8c6127d327620c93d2b2d00342a68e97b98a48d..2d0885c0cdc86f6959f08ef17ee80f84b31d25da 100644
--- a/app/app/src/main/res/values/colors.xml
+++ b/app/app/src/main/res/values/colors.xml
@@ -1,10 +1,6 @@
 <?xml version="1.0" encoding="utf-8"?>
 <resources>
-    <color name="purple_200">#FFBB86FC</color>
-    <color name="purple_500">#FF6200EE</color>
-    <color name="purple_700">#FF3700B3</color>
-    <color name="teal_200">#FF03DAC5</color>
-    <color name="teal_700">#FF018786</color>
-    <color name="black">#FF000000</color>
-    <color name="white">#FFFFFFFF</color>
-</resources>
\ No newline at end of file
+    <color name="red">#b53f3f</color>
+    <color name="green">#3f8e3d</color>
+    <color name="blue">@color/colorPrimary</color>
+</resources>
diff --git a/app/app/src/main/res/values/dimens.xml b/app/app/src/main/res/values/dimens.xml
deleted file mode 100644
index 125df8711919075db4015d1ec937aff0377904bb..0000000000000000000000000000000000000000
--- a/app/app/src/main/res/values/dimens.xml
+++ /dev/null
@@ -1,3 +0,0 @@
-<resources>
-    <dimen name="fab_margin">16dp</dimen>
-</resources>
\ No newline at end of file
diff --git a/app/app/src/main/res/values/strings.xml b/app/app/src/main/res/values/strings.xml
index 0fdfc53e7d1431ce937eda591f1d9bc6854eda98..6d00cacad6bdf14fe8b3002d71714eb18293ff99 100644
--- a/app/app/src/main/res/values/strings.xml
+++ b/app/app/src/main/res/values/strings.xml
@@ -1,12 +1,33 @@
 <resources>
-    <string name="app_name">PrivateStorage</string>
-    <string name="action_settings">Settings</string>
-    <!-- Strings used for fragments for navigation -->
-    <string name="first_fragment_label">First Fragment</string>
-    <string name="second_fragment_label">Second Fragment</string>
-    <string name="next">Next</string>
-    <string name="previous">Previous</string>
-
-    <string name="hello_first_fragment">Hello first fragment</string>
-    <string name="hello_second_fragment">Hello second fragment. Arg: %1$s</string>
-</resources>
\ No newline at end of file
+
+    <string name="app_id">com.chaquo.python.demo3</string>
+    <string name="app_name">Chaquopy demo</string>
+
+    <string name="ui_demo">Android UI demo</string>
+    <string name="ui_demo_summary">Demonstrates writing an app entirely in Python</string>
+    <string name="demo_caption">This activity was written entirely in Python using the Chaquopy
+        Python API. To view its source code, press the button above.</string>
+    <string name="view_source">View source</string>
+    <string name="demo_dialog">Dialog box</string>
+    <string name="demo_dialog_title">Dialog from Python</string>
+    <string name="demo_dialog_text">Select title color</string>
+    <string name="red">Red</string>
+    <string name="green">Green</string>
+    <string name="blue">Blue</string>
+    <string name="demo_notify">Notification</string>
+    <string name="demo_notify_title">Notification from Python</string>
+    <string name="demo_notify_text">Lorem ipsum dolor sit amet</string>
+    <string name="demo_toast">Toast</string>
+    <string name="demo_toast_text">Toast from Python</string>
+    <string name="demo_sound">Sound</string>
+    <string name="demo_vibrate">Vibration</string>
+
+    <string name="java_demo">Java API demo</string>
+    <string name="java_demo_summary">Demonstrates using a Python library from Java code</string>
+    <string name="java_demo_caption">This activity uses the Chaquopy Java API and the Python
+        Pygments library to format a copy of its own source code as HTML.</string>
+
+    <string name="java_unit_test">Java unit tests</string>
+    <string name="python_unit_test">Python unit tests</string>
+
+</resources>
diff --git a/app/app/src/main/res/values/themes.xml b/app/app/src/main/res/values/themes.xml
deleted file mode 100644
index 04b38cabcaec9c570900df8d69273d1bb8fbc67e..0000000000000000000000000000000000000000
--- a/app/app/src/main/res/values/themes.xml
+++ /dev/null
@@ -1,25 +0,0 @@
-<resources xmlns:tools="http://schemas.android.com/tools">
-    <!-- Base application theme. -->
-    <style name="Theme.PrivateStorage" parent="Theme.MaterialComponents.DayNight.DarkActionBar">
-        <!-- Primary brand color. -->
-        <item name="colorPrimary">@color/purple_500</item>
-        <item name="colorPrimaryVariant">@color/purple_700</item>
-        <item name="colorOnPrimary">@color/white</item>
-        <!-- Secondary brand color. -->
-        <item name="colorSecondary">@color/teal_200</item>
-        <item name="colorSecondaryVariant">@color/teal_700</item>
-        <item name="colorOnSecondary">@color/black</item>
-        <!-- Status bar color. -->
-        <item name="android:statusBarColor" tools:targetApi="l">?attr/colorPrimaryVariant</item>
-        <!-- Customize your theme here. -->
-    </style>
-
-    <style name="Theme.PrivateStorage.NoActionBar">
-        <item name="windowActionBar">false</item>
-        <item name="windowNoTitle">true</item>
-    </style>
-
-    <style name="Theme.PrivateStorage.AppBarOverlay" parent="ThemeOverlay.AppCompat.Dark.ActionBar" />
-
-    <style name="Theme.PrivateStorage.PopupOverlay" parent="ThemeOverlay.AppCompat.Light" />
-</resources>
\ No newline at end of file
diff --git a/app/app/src/main/res/xml/activity_main.xml b/app/app/src/main/res/xml/activity_main.xml
new file mode 100644
index 0000000000000000000000000000000000000000..8d586b169f912f30c841495ea168cab4d5c2ec03
--- /dev/null
+++ b/app/app/src/main/res/xml/activity_main.xml
@@ -0,0 +1,30 @@
+<?xml version="1.0" encoding="utf-8"?>
+<PreferenceScreen xmlns:android="http://schemas.android.com/apk/res/android"
+    android:orderingFromXml="true">
+
+    <Preference android:title="@string/repl" android:summary="@string/repl_summary">
+        <intent android:targetPackage="@string/app_id"
+            android:targetClass="com.chaquo.python.utils.ReplActivity"/>
+    </Preference>
+
+    <Preference android:title="@string/ui_demo" android:summary="@string/ui_demo_summary">
+        <intent android:targetPackage="@string/app_id"
+            android:targetClass="chaquopy.demo.ui_demo.UIDemoActivity"/>
+    </Preference>
+
+    <Preference android:title="@string/java_demo" android:summary="@string/java_demo_summary">
+        <intent android:targetPackage="@string/app_id"
+            android:targetClass="com.chaquo.python.demo.JavaDemoActivity"/>
+    </Preference>
+
+    <Preference android:title="@string/java_unit_test">
+        <intent android:targetPackage="@string/app_id"
+            android:targetClass="com.chaquo.python.demo.JavaTestActivity"/>
+    </Preference>
+
+    <Preference android:title="@string/python_unit_test">
+        <intent android:targetPackage="@string/app_id"
+            android:targetClass="com.chaquo.python.utils.PythonTestActivity"/>
+    </Preference>
+
+</PreferenceScreen>
\ No newline at end of file
diff --git a/app/app/src/main/res/xml/activity_ui_demo.xml b/app/app/src/main/res/xml/activity_ui_demo.xml
new file mode 100644
index 0000000000000000000000000000000000000000..4b3fcff66fba6e035a23867f83c364a19d1f09f8
--- /dev/null
+++ b/app/app/src/main/res/xml/activity_ui_demo.xml
@@ -0,0 +1,10 @@
+<?xml version="1.0" encoding="utf-8"?>
+<PreferenceScreen xmlns:android="http://schemas.android.com/apk/res/android"
+    android:orderingFromXml="true">
+
+    <Preference android:title="@string/demo_dialog" android:key="demo_dialog"/>
+    <Preference android:title="@string/demo_toast" android:key="demo_toast"/>
+    <Preference android:title="@string/demo_sound" android:key="demo_sound"/>
+    <Preference android:title="@string/demo_vibrate" android:key="demo_vibrate"/>
+
+</PreferenceScreen>
\ No newline at end of file
diff --git a/app/app/src/main/res/xml/backup_rules.xml b/app/app/src/main/res/xml/backup_rules.xml
deleted file mode 100644
index fa0f996d2c2a6bdd11f5371de4268c8389d6c720..0000000000000000000000000000000000000000
--- a/app/app/src/main/res/xml/backup_rules.xml
+++ /dev/null
@@ -1,13 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?><!--
-   Sample backup rules file; uncomment and customize as necessary.
-   See https://developer.android.com/guide/topics/data/autobackup
-   for details.
-   Note: This file is ignored for devices older that API 31
-   See https://developer.android.com/about/versions/12/backup-restore
--->
-<full-backup-content>
-    <!--
-   <include domain="sharedpref" path="."/>
-   <exclude domain="sharedpref" path="device.xml"/>
--->
-</full-backup-content>
\ No newline at end of file
diff --git a/app/app/src/main/res/xml/data_extraction_rules.xml b/app/app/src/main/res/xml/data_extraction_rules.xml
deleted file mode 100644
index 9ee9997b0b4726e57c27b2f7b21462b604ff8a88..0000000000000000000000000000000000000000
--- a/app/app/src/main/res/xml/data_extraction_rules.xml
+++ /dev/null
@@ -1,19 +0,0 @@
-<?xml version="1.0" encoding="utf-8"?><!--
-   Sample data extraction rules file; uncomment and customize as necessary.
-   See https://developer.android.com/about/versions/12/backup-restore#xml-changes
-   for details.
--->
-<data-extraction-rules>
-    <cloud-backup>
-        <!-- TODO: Use <include> and <exclude> to control what is backed up.
-        <include .../>
-        <exclude .../>
-        -->
-    </cloud-backup>
-    <!--
-    <device-transfer>
-        <include .../>
-        <exclude .../>
-    </device-transfer>
-    -->
-</data-extraction-rules>
\ No newline at end of file
diff --git a/app/app/src/test/java/com/chaquo/python/utils/BufferedLiveEventTest.java b/app/app/src/test/java/com/chaquo/python/utils/BufferedLiveEventTest.java
new file mode 100644
index 0000000000000000000000000000000000000000..7156b52dca1d068466fc8e0cf3314f254c3c9309
--- /dev/null
+++ b/app/app/src/test/java/com/chaquo/python/utils/BufferedLiveEventTest.java
@@ -0,0 +1,91 @@
+package com.chaquo.python.utils;
+
+import androidx.annotation.*;
+import androidx.arch.core.executor.testing.*;
+import androidx.lifecycle.*;
+import androidx.lifecycle.Observer;
+import java.util.*;
+import org.junit.*;
+import org.junit.rules.*;
+
+import static androidx.lifecycle.Lifecycle.Event.*;
+import static org.junit.Assert.*;
+
+public class BufferedLiveEventTest implements LifecycleOwner {
+
+    @Rule public TestRule instantExecutorRule = new InstantTaskExecutorRule();
+    @Rule public ExpectedException thrown = ExpectedException.none();
+
+    private LifecycleRegistry lifecycle = new LifecycleRegistry(this);
+
+    private BufferedLiveEvent<String> ble = new BufferedLiveEvent<>();
+
+    private static class MockObserver<T> implements Observer<T> {
+        public List<T> observed = new ArrayList<>();
+
+        @Override public void onChanged(@Nullable T t) {
+            observed.add(t);
+        }
+    }
+
+    private MockObserver<String> observer = new MockObserver<>();
+
+    @Before public void setUp() throws Exception {
+        ble.observe(this, observer);
+        assertObserved();
+    }
+
+    @NonNull @Override public Lifecycle getLifecycle() {
+        return lifecycle;
+    }
+
+    @Test public void startStopStart() {
+        ble.setValue("a");
+        assertObserved();
+        lifecycle.handleLifecycleEvent(ON_START);
+        assertObserved("a");
+        ble.setValue("b");
+        assertObserved("b");
+        ble.setValue("c");
+        lifecycle.handleLifecycleEvent(ON_STOP);
+        ble.setValue("d");
+        ble.setValue("e");
+        assertObserved("c");
+        lifecycle.handleLifecycleEvent(ON_START);
+        assertObserved("d", "e");
+    }
+
+    @Test public void nullValue() {
+        lifecycle.handleLifecycleEvent(ON_START);
+        ble.setValue("a");
+        ble.setValue(null);
+        ble.setValue("b");
+        assertObserved("a", null, "b");
+    }
+
+    @Test public void observeMultiple() {
+        thrown.expect(IllegalStateException.class);
+        thrown.expectMessage("Cannot register multiple observers on a SingleLiveEvent");
+        ble.observe(this, new Observer<String>() {
+            @Override public void onChanged(@Nullable String s) {}
+        });
+    }
+
+    @Test public void observeRemoveObserve() {
+        lifecycle.handleLifecycleEvent(ON_START);
+        ble.setValue("a");
+        assertObserved("a");
+        ble.removeObserver(observer);
+        ble.setValue("c");
+        ble.setValue("d");
+        assertObserved();
+        ble.observe(this, observer);
+        assertObserved("c", "d");
+    }
+
+    public void assertObserved(String... expected) {
+        assertEquals(Arrays.asList(expected), observer.observed);
+        observer.observed.clear();
+    }
+
+}
diff --git a/app/app/src/test/java/com/example/privatestorage/ExampleUnitTest.kt b/app/app/src/test/java/com/example/privatestorage/ExampleUnitTest.kt
deleted file mode 100644
index 171bd4c6c16021ef01c41b61077a26d4fa74c894..0000000000000000000000000000000000000000
--- a/app/app/src/test/java/com/example/privatestorage/ExampleUnitTest.kt
+++ /dev/null
@@ -1,17 +0,0 @@
-package com.example.privatestorage
-
-import org.junit.Test
-
-import org.junit.Assert.*
-
-/**
- * Example local unit test, which will execute on the development machine (host).
- *
- * See [testing documentation](http://d.android.com/tools/testing).
- */
-class ExampleUnitTest {
-    @Test
-    fun addition_isCorrect() {
-        assertEquals(4, 2 + 2)
-    }
-}
\ No newline at end of file
diff --git a/app/app/src/utils/java/com/chaquo/python/utils/App.java b/app/app/src/utils/java/com/chaquo/python/utils/App.java
new file mode 100644
index 0000000000000000000000000000000000000000..6526a42190537665f92dc805082705659f53c2b7
--- /dev/null
+++ b/app/app/src/utils/java/com/chaquo/python/utils/App.java
@@ -0,0 +1,20 @@
+package com.chaquo.python.utils;
+
+import android.content.*;
+import androidx.preference.*;
+import com.chaquo.python.android.*;
+
+
+public class App extends PyApplication {
+
+    public static App context;
+    public static SharedPreferences prefs;
+
+    @Override
+    public void onCreate() {
+        super.onCreate();
+        context = this;
+        prefs = PreferenceManager.getDefaultSharedPreferences(this);
+    }
+
+}
\ No newline at end of file
diff --git a/app/app/src/utils/java/com/chaquo/python/utils/BufferedLiveEvent.java b/app/app/src/utils/java/com/chaquo/python/utils/BufferedLiveEvent.java
new file mode 100644
index 0000000000000000000000000000000000000000..573e99dc8718609e0c755ddae773f6d015b8d18c
--- /dev/null
+++ b/app/app/src/utils/java/com/chaquo/python/utils/BufferedLiveEvent.java
@@ -0,0 +1,45 @@
+package com.chaquo.python.utils;
+
+import android.os.*;
+import androidx.annotation.*;
+import java.util.*;
+
+/** Similar to SimpleLiveEvent, but any values set while inactive will be buffered. As soon as
+ * we have an active observer, it will be notified of those values in the same order as they were
+ * set. */
+public class BufferedLiveEvent<T> extends SingleLiveEvent<T> {
+
+    private ArrayList<T> mBuffer = new ArrayList<>();
+    private Handler mHandler;
+
+    /** Unlike in the base class, multiple calls to postData will always result in multiple values
+     * being notified to the observer. */
+    @Override public void postValue(@Nullable final T value) {
+        // Delay initialization for unit tests.
+        if (mHandler == null) {
+            mHandler = new Handler(Looper.getMainLooper());
+        }
+        mHandler.post(new Runnable() {
+            @Override public void run() {
+                setValue(value);
+            }
+        });
+    }
+
+    @Override public void setValue(@Nullable T t) {
+        if (hasActiveObservers() && mBuffer.isEmpty()) {  // See onActive
+            super.setValue(t);
+        } else {
+            mBuffer.add(t);
+        }
+    }
+
+    @Override protected void onActive() {
+        // Don't use a foreach loop, an observer might call setValue and lengthen the buffer.
+        for (int i = 0; i < mBuffer.size(); i++) {
+            super.setValue(mBuffer.get(i));
+        }
+        mBuffer.clear();
+    }
+
+}
diff --git a/app/app/src/utils/java/com/chaquo/python/utils/ConsoleActivity.java b/app/app/src/utils/java/com/chaquo/python/utils/ConsoleActivity.java
new file mode 100644
index 0000000000000000000000000000000000000000..a07807c623bae96ba2e1a4c698f1021289c0893b
--- /dev/null
+++ b/app/app/src/utils/java/com/chaquo/python/utils/ConsoleActivity.java
@@ -0,0 +1,398 @@
+package com.chaquo.python.utils;
+
+import androidx.annotation.NonNull;
+import android.app.*;
+import android.graphics.*;
+import android.os.*;
+import android.text.*;
+import android.text.style.*;
+import android.view.*;
+import android.view.inputmethod.*;
+import android.widget.*;
+import androidx.annotation.*;
+import androidx.appcompat.app.*;
+import androidx.core.content.*;
+import androidx.lifecycle.*;
+
+public abstract class ConsoleActivity extends AppCompatActivity
+implements ViewTreeObserver.OnGlobalLayoutListener, ViewTreeObserver.OnScrollChangedListener {
+
+    // Because tvOutput has freezesText enabled, letting it get too large can cause a
+    // TransactionTooLargeException. The limit isn't in the saved state itself, but in the
+    // Binder transaction which transfers it to the system server. So it doesn't happen if
+    // you're rotating the screen, but it does happen when you press Back.
+    //
+    // The exception message shows the size of the failed transaction, so I can determine from
+    // experiment that the limit is about 500 KB, and each character consumes 4 bytes.
+    private final int MAX_SCROLLBACK_LEN = 100000;
+
+    private EditText etInput;
+    private ScrollView svOutput;
+    private TextView tvOutput;
+    private int outputWidth = -1, outputHeight = -1;
+
+    enum Scroll {
+        TOP, BOTTOM
+    }
+    private Scroll scrollRequest;
+    
+    public static class ConsoleModel extends ViewModel {
+        boolean pendingNewline = false;  // Prevent empty line at bottom of screen
+        int scrollChar = 0;              // Character offset of the top visible line.
+        int scrollAdjust = 0;            // Pixels by which that line is scrolled above the top
+                                         //   (prevents movement when keyboard hidden/shown).
+    }
+    private ConsoleModel consoleModel;
+
+    protected Task task;
+
+    @Override
+    protected void onCreate(Bundle savedInstanceState) {
+        super.onCreate(savedInstanceState);
+        consoleModel = ViewModelProviders.of(this).get(ConsoleModel.class);
+        task = ViewModelProviders.of(this).get(getTaskClass());
+        setContentView(resId("layout", "activity_console"));
+        createInput();
+        createOutput();
+    }
+
+    protected abstract Class<? extends Task> getTaskClass();
+
+    private void createInput() {
+        etInput = findViewById(resId("id", "etInput"));
+
+        // Strip formatting from pasted text.
+        etInput.addTextChangedListener(new TextWatcher() {
+            public void beforeTextChanged(CharSequence s, int start, int count, int after) {}
+            public void onTextChanged(CharSequence s, int start, int before, int count) {}
+            public void afterTextChanged(Editable e) {
+                for (CharacterStyle cs : e.getSpans(0, e.length(), CharacterStyle.class)) {
+                    e.removeSpan(cs);
+                }
+            }
+        });
+
+        // At least on API level 28, if an ACTION_UP is lost during a rotation, then the app
+        // (or any other app which takes focus) will receive an endless stream of ACTION_DOWNs
+        // until the key is pressed again. So we react to ACTION_UP instead.
+        etInput.setOnEditorActionListener(new TextView.OnEditorActionListener() {
+            @Override
+            public boolean onEditorAction(TextView v, int actionId, KeyEvent event) {
+                if (actionId == EditorInfo.IME_ACTION_DONE ||
+                    (event != null && event.getAction() == KeyEvent.ACTION_UP)) {
+                    String text = etInput.getText().toString() + "\n";
+                    etInput.setText("");
+                    output(span(text, new StyleSpan(Typeface.BOLD)));
+                    scrollTo(Scroll.BOTTOM);
+                    task.onInput(text);
+                }
+
+                // If we return false on ACTION_DOWN, we won't be given the ACTION_UP.
+                return true;
+            }
+        });
+
+        task.inputEnabled.observe(this, new Observer<Boolean>() {
+            @Override public void onChanged(@Nullable Boolean enabled) {
+                InputMethodManager imm = (InputMethodManager) getSystemService(INPUT_METHOD_SERVICE);
+                if (enabled) {
+                    etInput.setVisibility(View.VISIBLE);
+                    etInput.setEnabled(true);
+
+                    // requestFocus alone doesn't always bring up the soft keyboard during startup
+                    // on the Nexus 4 with API level 22: probably some race condition. (After
+                    // rotation with input *already* enabled, the focus may be overridden by
+                    // onRestoreInstanceState, which will run after this observer.)
+                    etInput.requestFocus();
+                    imm.showSoftInput(etInput, InputMethodManager.SHOW_IMPLICIT);
+                } else {
+                    // Disable rather than hide, otherwise tvOutput gets a gray background on API
+                    // level 26, like tvCaption in the main menu when you press an arrow key.
+                    etInput.setEnabled(false);
+                    imm.hideSoftInputFromWindow(tvOutput.getWindowToken(), 0);
+                }
+            }
+        });
+    }
+
+    private void createOutput() {
+        svOutput = findViewById(resId("id", "svOutput"));
+        svOutput.getViewTreeObserver().addOnGlobalLayoutListener(this);
+
+        tvOutput = findViewById(resId("id", "tvOutput"));
+        if (Build.VERSION.SDK_INT >= 23) {
+            // noinspection WrongConstant
+            tvOutput.setBreakStrategy(Layout.BREAK_STRATEGY_SIMPLE);
+        }
+        // Don't start observing task.output yet: we need to restore the scroll position first so
+        // we maintain the scrolled-to-bottom state.
+    }
+
+    @Override protected void onRestoreInstanceState(@NonNull Bundle savedInstanceState) {
+        // Don't restore the UI state unless we have the non-UI state as well.
+        if (task.getState() != Thread.State.NEW) {
+            super.onRestoreInstanceState(savedInstanceState);
+        }
+    }
+
+    @Override protected void onResume() {
+        super.onResume();
+        // Needs to be in onResume rather than onStart because onRestoreInstanceState runs
+        // between them.
+        if (task.getState() == Thread.State.NEW) {
+            task.start();
+        }
+    }
+
+    @Override protected void onPause() {
+        super.onPause();
+        saveScroll();  // Necessary to save bottom position in case we've never scrolled.
+    }
+
+    // This callback is run after onResume, after each layout pass. If a view's size, position
+    // or visibility has changed, the new values will be visible here.
+    @Override public void onGlobalLayout() {
+        if (outputWidth != svOutput.getWidth() || outputHeight != svOutput.getHeight()) {
+            // Can't register this listener in onCreate on API level 15
+            // (https://stackoverflow.com/a/35054919).
+            if (outputWidth == -1) {
+                svOutput.getViewTreeObserver().addOnScrollChangedListener(this);
+            }
+
+            // Either we've just started up, or the keyboard has been hidden or shown.
+            outputWidth = svOutput.getWidth();
+            outputHeight = svOutput.getHeight();
+            restoreScroll();
+        } else if (scrollRequest != null) {
+            int y = -1;
+            switch (scrollRequest) {
+                case TOP:
+                    y = 0;
+                    break;
+                case BOTTOM:
+                    y = tvOutput.getHeight();
+                    break;
+            }
+
+            // Don't use smooth scroll, because if an output call happens while it's animating
+            // towards the bottom, isScrolledToBottom will believe we've left the bottom and
+            // auto-scrolling will stop. Don't use fullScroll either, because not only does it use
+            // smooth scroll, it also grabs focus.
+            svOutput.scrollTo(0, y);
+            scrollRequest = null;
+        }
+    }
+
+   @Override public void onScrollChanged() {
+        saveScroll();
+    }
+
+    // After a rotation, a ScrollView will restore the previous pixel scroll position. However, due
+    // to re-wrapping, this may result in a completely different piece of text being visible. We'll
+    // try to maintain the text position of the top line, unless the view is scrolled to the bottom,
+    // in which case we'll maintain that. Maintaining the bottom line will also cause a scroll
+    // adjustment when the keyboard's hidden or shown.
+    private void saveScroll() {
+        if (isScrolledToBottom()) {
+            consoleModel.scrollChar = tvOutput.getText().length();
+            consoleModel.scrollAdjust = 0;
+        } else {
+            int scrollY = svOutput.getScrollY();
+            Layout layout = tvOutput.getLayout();
+            if (layout != null) {  // See note in restoreScroll
+                int line = layout.getLineForVertical(scrollY);
+                consoleModel.scrollChar = layout.getLineStart(line);
+                consoleModel.scrollAdjust = scrollY - layout.getLineTop(line);
+            }
+        }
+    }
+
+    private void restoreScroll() {
+        removeCursor();
+
+        // getLayout sometimes returns null even when called from onGlobalLayout. The
+        // documentation says this can happen if the "text or width has recently changed", but
+        // does not define "recently". See Electron Cash issues #1330 and #1592.
+        Layout layout = tvOutput.getLayout();
+        if (layout != null) {
+            int line = layout.getLineForOffset(consoleModel.scrollChar);
+            svOutput.scrollTo(0, layout.getLineTop(line) + consoleModel.scrollAdjust);
+        }
+
+        // If we are now scrolled to the bottom, we should stick there. (scrollTo probably won't
+        // trigger onScrollChanged unless the scroll actually changed.)
+        saveScroll();
+
+        task.output.removeObservers(this);
+        task.output.observe(this, new Observer<CharSequence>() {
+            @Override public void onChanged(@Nullable CharSequence text) {
+                output(text);
+            }
+        });
+    }
+
+    private boolean isScrolledToBottom() {
+        int visibleHeight = (svOutput.getHeight() - svOutput.getPaddingTop() -
+                             svOutput.getPaddingBottom());
+        int maxScroll = Math.max(0, tvOutput.getHeight() - visibleHeight);
+        return (svOutput.getScrollY() >= maxScroll);
+    }
+
+    @Override
+    public boolean onCreateOptionsMenu(Menu menu) {
+        MenuInflater mi = getMenuInflater();
+        mi.inflate(resId("menu", "top_bottom"), menu);
+        return true;
+    }
+
+    @Override public boolean onOptionsItemSelected(MenuItem item) {
+        int id = item.getItemId();
+        if (id == resId("id", "menu_top")) {
+            scrollTo(Scroll.TOP);
+        } else if (id == resId("id", "menu_bottom")) {
+            scrollTo(Scroll.BOTTOM);
+        } else {
+            return false;
+        }
+        return true;
+    }
+
+    public static Spannable span(CharSequence text, Object... spans) {
+        Spannable spanText = new SpannableStringBuilder(text);
+        for (Object span : spans) {
+            spanText.setSpan(span, 0, text.length(), 0);
+        }
+        return spanText;
+    }
+
+    private void output(CharSequence text) {
+        removeCursor();
+        if (consoleModel.pendingNewline) {
+            tvOutput.append("\n");
+            consoleModel.pendingNewline = false;
+        }
+        if (text.charAt(text.length() - 1) == '\n') {
+            tvOutput.append(text.subSequence(0, text.length() - 1));
+            consoleModel.pendingNewline = true;
+        } else {
+            tvOutput.append(text);
+        }
+
+        Editable scrollback = (Editable) tvOutput.getText();
+        if (scrollback.length() > MAX_SCROLLBACK_LEN) {
+            scrollback.delete(0, MAX_SCROLLBACK_LEN / 10);
+        }
+
+        // Changes to the TextView height won't be reflected by getHeight until after the
+        // next layout pass, so isScrolledToBottom is safe here.
+        if (isScrolledToBottom()) {
+            scrollTo(Scroll.BOTTOM);
+        }
+    }
+
+    // Don't actually scroll until the next onGlobalLayout, when we'll know what the new TextView
+    // height is.
+    private void scrollTo(Scroll request) {
+        // The "top" button should take priority over an auto-scroll.
+        if (scrollRequest != Scroll.TOP) {
+            scrollRequest = request;
+            svOutput.requestLayout();
+        }
+    }
+
+    // Because we've set textIsSelectable, the TextView will create an invisible cursor (i.e. a
+    // zero-length selection) during startup, and re-create it if necessary whenever the user taps
+    // on the view. When a TextView is focused and it has a cursor, it will adjust its containing
+    // ScrollView whenever the text changes in an attempt to keep the cursor on-screen.
+    // textIsSelectable implies focusable, so if there are no other focusable views in the layout,
+    // then it will always be focused.
+    //
+    // To avoid interference from this, we'll remove any cursor before we adjust the scroll.
+    // A non-zero-length selection is left untouched and may affect the scroll in the normal way,
+    // which is fine because it'll only exist if the user deliberately created it.
+    private void removeCursor() {
+        Spannable text = (Spannable) tvOutput.getText();
+        int selStart = Selection.getSelectionStart(text);
+        int selEnd = Selection.getSelectionEnd(text);
+
+        // When textIsSelectable is set, the buffer type after onRestoreInstanceState is always
+        // Spannable, regardless of the value of bufferType. It would then become Editable (and
+        // have a cursor added), during the first call to append(). Make that happen now so we can
+        // remove the cursor before append() is called.
+        if (!(text instanceof Editable)) {
+            tvOutput.setText(text, TextView.BufferType.EDITABLE);
+            text = (Editable) tvOutput.getText();
+
+            // setText removes any existing selection, at least on API level 26.
+            if (selStart >= 0) {
+                Selection.setSelection(text, selStart, selEnd);
+            }
+        }
+
+        if (selStart >= 0 && selStart == selEnd) {
+            Selection.removeSelection(text);
+        }
+
+    }
+
+    public int resId(String type, String name) {
+        return Utils.resId(this, type, name);
+    }
+
+    // =============================================================================================
+
+    public static abstract class Task extends AndroidViewModel {
+
+        private Thread.State state = Thread.State.NEW;
+
+        public void start() {
+            new Thread(() -> {
+                try {
+                    Task.this.run();
+                    output(spanColor("[Finished]", resId("color", "console_meta")));
+                } finally {
+                    inputEnabled.postValue(false);
+                    state = Thread.State.TERMINATED;
+                }
+            }).start();
+            state = Thread.State.RUNNABLE;
+        }
+
+        public Thread.State getState() { return state; }
+
+        public MutableLiveData<Boolean> inputEnabled = new MutableLiveData<>();
+        public BufferedLiveEvent<CharSequence> output = new BufferedLiveEvent<>();
+
+        public Task(Application app) {
+            super(app);
+            inputEnabled.setValue(false);
+        }
+
+        /** Override this method to provide the task's implementation. It will be called on a
+         *  background thread. */
+        public abstract void run();
+
+        /** Called on the UI thread each time the user enters some input, A trailing newline is
+         * always included. The base class implementation does nothing. */
+        public void onInput(String text) {}
+
+        public void output(final CharSequence text) {
+            if (text.length() == 0) return;
+            output.postValue(text);
+        }
+
+        public void outputError(CharSequence text) {
+            output(spanColor(text, resId("color", "console_error")));
+        }
+
+        public Spannable spanColor(CharSequence text, int colorId) {
+            int color = ContextCompat.getColor(this.getApplication(), colorId);
+            return span(text, new ForegroundColorSpan(color));
+        }
+
+        public int resId(String type, String name) {
+            return Utils.resId(getApplication(), type, name);
+        }
+    }
+
+}
diff --git a/app/app/src/utils/java/com/chaquo/python/utils/MainActivity.java b/app/app/src/utils/java/com/chaquo/python/utils/MainActivity.java
new file mode 100644
index 0000000000000000000000000000000000000000..d47d76299bbd1ce0221f9f386df4754363725697
--- /dev/null
+++ b/app/app/src/utils/java/com/chaquo/python/utils/MainActivity.java
@@ -0,0 +1,40 @@
+package com.chaquo.python.utils;
+
+import android.content.pm.*;
+import android.os.*;
+import android.text.method.*;
+import android.widget.*;
+import androidx.appcompat.app.*;
+import androidx.preference.*;
+
+public class MainActivity extends AppCompatActivity {
+
+    @Override
+    protected void onCreate(Bundle savedInstanceState) {
+        super.onCreate(savedInstanceState);
+        try {
+            String version = getPackageManager().getPackageInfo(getPackageName(), 0).versionName;
+            setTitle(getTitle() + " " + version);
+        } catch (PackageManager.NameNotFoundException ignored) {}
+
+        setContentView(resId("layout", "activity_menu"));
+        ((TextView)findViewById(resId("id", "tvCaption")))
+            .setText(resId("string", "main_caption"));
+        getSupportFragmentManager().beginTransaction()
+            .replace(resId("id", "flMenu"), new MenuFragment())
+            .commit();
+
+        ((TextView)findViewById(resId("id", "tvCaption"))).setMovementMethod(LinkMovementMethod.getInstance());
+    }
+
+    public static class MenuFragment extends PreferenceFragmentCompat {
+        @Override
+        public void onCreatePreferences(Bundle savedInstanceState, String rootKey) {
+            addPreferencesFromResource(Utils.resId(getContext(), "xml", "activity_main"));
+        }
+    }
+
+    public int resId(String type, String name) {
+        return Utils.resId(this, type, name);
+    }
+}
diff --git a/app/app/src/utils/java/com/chaquo/python/utils/PythonConsoleActivity.java b/app/app/src/utils/java/com/chaquo/python/utils/PythonConsoleActivity.java
new file mode 100644
index 0000000000000000000000000000000000000000..893a914633053d6cc0a1707a67d2c4a891bbc281
--- /dev/null
+++ b/app/app/src/utils/java/com/chaquo/python/utils/PythonConsoleActivity.java
@@ -0,0 +1,118 @@
+package com.chaquo.python.utils;
+
+import android.app.*;
+import android.os.*;
+import android.text.*;
+import android.util.*;
+import android.widget.*;
+import androidx.lifecycle.*;
+import com.chaquo.python.*;
+
+/** Base class for a console-based activity that will run Python code. sys.stdout and sys.stderr
+ * will be directed to the output view whenever the activity is resumed. If the Python code
+ * caches their values, it can direct output to the activity even when it's paused.
+ *
+ * Unless inputType is InputType.TYPE_NULL, sys.stdin will also be redirected whenever
+ * the activity is resumed. The input box will initially be hidden, and will be displayed the
+ * first time sys.stdin is read. */
+public abstract class PythonConsoleActivity extends ConsoleActivity {
+
+    protected Task task;
+
+    @Override protected void onCreate(Bundle savedInstanceState) {
+        super.onCreate(savedInstanceState);
+        task = ViewModelProviders.of(this).get(getTaskClass());
+        if (task.inputType != InputType.TYPE_NULL) {
+            ((TextView) findViewById(resId("id", "etInput"))).setInputType(task.inputType);
+        }
+    }
+
+    protected abstract Class<? extends Task> getTaskClass();
+
+    @Override protected void onResume() {
+        task.resumeStreams();
+        super.onResume();  // Starts the task thread.
+    }
+
+    @Override protected void onPause() {
+        super.onPause();
+        if (! isChangingConfigurations()) {
+            task.pauseStreams();
+        }
+    }
+
+    // =============================================================================================
+
+    public static abstract class Task extends ConsoleActivity.Task {
+
+        protected Python py = Python.getInstance();
+        private PyObject console = py.getModule("chaquopy.utils.console");
+        private PyObject sys = py.getModule("sys");
+        int inputType;
+        private PyObject stdin, stdout, stderr;
+        private PyObject realStdin, realStdout, realStderr;
+
+        public Task(Application app) {
+            this(app, InputType.TYPE_CLASS_TEXT);
+        }
+
+        public Task(Application app, int inputType) {
+            super(app);
+            this.inputType = inputType;
+            if (inputType != InputType.TYPE_NULL) {
+                realStdin = sys.get("stdin");
+                stdin = console.callAttr("ConsoleInputStream", this);
+            }
+
+            realStdout = sys.get("stdout");
+            realStderr = sys.get("stderr");
+            stdout = redirectOutput(realStdout, "output");
+            stderr = redirectOutput(realStderr, "outputError");
+        }
+
+        // We're not using method references, because that would prevent using this code with
+        // old versions of Chaquopy.
+        private PyObject redirectOutput(PyObject stream, String methodName) {
+            return console.callAttr("ConsoleOutputStream", stream, this, methodName);
+        }
+
+        public void resumeStreams() {
+            if (stdin != null) {
+                sys.put("stdin", stdin);
+            }
+            sys.put("stdout", stdout);
+            sys.put("stderr", stderr);
+        }
+
+        public void pauseStreams() {
+            if (realStdin != null) {
+                sys.put("stdin", realStdin);
+            }
+            sys.put("stdout", realStdout);
+            sys.put("stderr", realStderr);
+        }
+
+        @SuppressWarnings("unused")  // Called from Python
+        public void onInputState(boolean blocked) {
+            if (blocked) {
+                inputEnabled.postValue(true);
+            }
+        }
+
+        @Override public void onInput(String text) {
+            if (text != null) {
+                // Messages which are empty (or only consist of newlines) will not be logged.
+                Log.i("python.stdin", text.equals("\n") ? " " : text);
+            }
+            stdin.callAttr("on_input", text);
+        }
+
+        @Override protected void onCleared() {
+            super.onCleared();
+            if (stdin != null) {
+                onInput(null);  // Signals EOF
+            }
+        }
+    }
+
+}
diff --git a/app/app/src/utils/java/com/chaquo/python/utils/PythonTestActivity.java b/app/app/src/utils/java/com/chaquo/python/utils/PythonTestActivity.java
new file mode 100644
index 0000000000000000000000000000000000000000..d169462130d085393422fe1946506195f3a0b14d
--- /dev/null
+++ b/app/app/src/utils/java/com/chaquo/python/utils/PythonTestActivity.java
@@ -0,0 +1,34 @@
+package com.chaquo.python.utils;
+
+import android.app.*;
+import android.text.*;
+import com.chaquo.python.*;
+
+public class PythonTestActivity extends PythonConsoleActivity {
+
+    @Override protected Class<? extends Task> getTaskClass() {
+        return Task.class;
+    }
+
+    // =============================================================================================
+
+    public static class Task extends PythonConsoleActivity.Task {
+        public Task(Application app) {
+            super(app, InputType.TYPE_NULL);  // test_android expects stdin to return EOF.
+        }
+
+        @SuppressWarnings("unused")  // For pkgtest app.
+        public Task(Application app, int inputType) {
+            super(app, inputType);
+        }
+
+        @Override public void run() {
+            PyObject unittest = py.getModule("unittest");
+            PyObject runner = unittest.callAttr("TextTestRunner", new Kwarg("verbosity", 2));
+            PyObject loader = unittest.get("defaultTestLoader");
+            PyObject suite = loader.callAttr("loadTestsFromModule", py.getModule("chaquopy.test"));
+            runner.callAttr("run", suite);
+        }
+    }
+
+}
diff --git a/app/app/src/utils/java/com/chaquo/python/utils/ReplActivity.java b/app/app/src/utils/java/com/chaquo/python/utils/ReplActivity.java
new file mode 100644
index 0000000000000000000000000000000000000000..4ebdf0b0f8bdb90da9bc4e213daee96add743323
--- /dev/null
+++ b/app/app/src/utils/java/com/chaquo/python/utils/ReplActivity.java
@@ -0,0 +1,40 @@
+package com.chaquo.python.utils;
+
+import android.app.*;
+import android.text.*;
+
+public class ReplActivity extends PythonConsoleActivity {
+
+    @Override protected Class<? extends Task> getTaskClass() {
+        return Task.class;
+    }
+
+    // Maintain REPL state unless the loop has been terminated, e.g. by typing `exit()`. Requires
+    // the activity to be in its own task (see AndroidManifest).
+    @Override public void onBackPressed() {
+        if (task.getState() == Thread.State.RUNNABLE) {
+            moveTaskToBack(true);
+        } else {
+            super.onBackPressed();
+        }
+    }
+
+    // =============================================================================================
+
+    public static class Task extends PythonConsoleActivity.Task {
+        // VISIBLE_PASSWORD is necessary to prevent some versions of the Google keyboard from
+        // displaying the suggestion bar.
+        public Task(Application app) {
+            super(app, (InputType.TYPE_CLASS_TEXT +
+                        InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS +
+                        InputType.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD));
+        }
+
+        @Override public void run() {
+            py.getModule("chaquopy.utils.repl")
+                .callAttr("AndroidConsole", App.context)
+                .callAttr("interact");
+        }
+    }
+
+}
diff --git a/app/app/src/utils/java/com/chaquo/python/utils/SingleLiveEvent.java b/app/app/src/utils/java/com/chaquo/python/utils/SingleLiveEvent.java
new file mode 100644
index 0000000000000000000000000000000000000000..895be1e9a1e863246ef558ac466fcbdf946e86d5
--- /dev/null
+++ b/app/app/src/utils/java/com/chaquo/python/utils/SingleLiveEvent.java
@@ -0,0 +1,88 @@
+/*
+ *  Copyright 2017 Google Inc.
+ *
+ *  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 com.chaquo.python.utils;
+
+import androidx.annotation.MainThread;
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.lifecycle.LifecycleOwner;
+import androidx.lifecycle.MutableLiveData;
+import androidx.lifecycle.Observer;
+
+import java.util.concurrent.atomic.AtomicBoolean;
+
+/**
+ * A lifecycle-aware observable that sends only new updates after subscription, used for events like
+ * navigation and Snackbar messages.
+ * <p>
+ * This avoids a common problem with events: on configuration change (like rotation) an update
+ * can be emitted if the observer is active. This LiveData only calls the observable if there's an
+ * explicit call to setValue() or call().
+ * <p>
+ * Note that only one observer is going to be notified of changes.
+ */
+public class SingleLiveEvent<T> extends MutableLiveData<T> {
+
+    private static final String TAG = "SingleLiveEvent";
+
+    private Observer<? super T> mObserver;
+    private Observer<T> mObserverWrapper;
+
+    private final AtomicBoolean mPending = new AtomicBoolean(false);
+
+    @MainThread
+    public void observe(@NonNull LifecycleOwner owner, @NonNull Observer<? super T> observer) {
+        if (mObserver != null) {
+            throw new IllegalStateException
+                ("Cannot register multiple observers on a SingleLiveEvent");
+        }
+
+        mObserver = observer;
+        mObserverWrapper = new Observer<T>() {
+            @Override
+            public void onChanged(@Nullable T t) {
+                if (mPending.compareAndSet(true, false)) {
+                    mObserver.onChanged(t);
+                }
+            }
+        };
+        super.observe(owner, mObserverWrapper);
+    }
+
+    @Override
+    public void removeObserver(@NonNull Observer<? super T> observer) {
+        if (observer == mObserverWrapper ||  // Will happen when lifecycle owner is destroyed.
+            observer == mObserver) {
+            super.removeObserver(mObserverWrapper);
+            mObserver = mObserverWrapper = null;
+        }
+    }
+
+    @MainThread
+    public void setValue(@Nullable T t) {
+        mPending.set(true);
+        super.setValue(t);
+    }
+
+    /**
+     * Used for cases where T is Void, to make calls cleaner.
+     */
+    @MainThread
+    public void call() {
+        setValue(null);
+    }
+}
\ No newline at end of file
diff --git a/app/app/src/utils/java/com/chaquo/python/utils/Utils.java b/app/app/src/utils/java/com/chaquo/python/utils/Utils.java
new file mode 100644
index 0000000000000000000000000000000000000000..8e6f687d2d7a455e47b9ab0447a2067fd5a4dcce
--- /dev/null
+++ b/app/app/src/utils/java/com/chaquo/python/utils/Utils.java
@@ -0,0 +1,14 @@
+package com.chaquo.python.utils;
+
+import android.content.*;
+import android.content.res.*;
+
+public class Utils {
+    /** Make this package easy to copy to other apps by avoiding direct "R" references. (It
+     * would be better to do this by distributing it along with its resources in an AAR, but
+     * Chaquopy doesn't support getting Python code from an AAR yet.)*/
+    public static int resId(Context context, String type, String name) {
+        Resources resources = context.getResources();
+        return resources.getIdentifier(name, type, context.getApplicationInfo().packageName);
+    }
+}
diff --git a/app/app/src/utils/python/chaquopy/__init__.py b/app/app/src/utils/python/chaquopy/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/app/app/src/utils/python/chaquopy/utils/__init__.py b/app/app/src/utils/python/chaquopy/utils/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
diff --git a/app/app/src/utils/python/chaquopy/utils/console.py b/app/app/src/utils/python/chaquopy/utils/console.py
new file mode 100644
index 0000000000000000000000000000000000000000..261eba0ce02e257428ea30451ee239a43f6f64c1
--- /dev/null
+++ b/app/app/src/utils/python/chaquopy/utils/console.py
@@ -0,0 +1,103 @@
+from io import TextIOBase
+from queue import Queue
+
+
+class ConsoleInputStream(TextIOBase):
+    """Receives input in on_input in one thread (non-blocking), and provides a read interface
+    in another thread (blocking).
+    """
+    def __init__(self, task):
+        self.task = task
+        self.queue = Queue()
+        self.buffer = ""
+        self.eof = False
+
+    @property
+    def encoding(self):
+        return "UTF-8"
+
+    @property
+    def errors(self):
+        return "strict"  # UTF-8 encoding should never fail.
+
+    def readable(self):
+        return True
+
+    def on_input(self, input):
+        if self.eof:
+            raise ValueError("Can't add more input after EOF")
+        if input is None:
+            self.eof = True
+        self.queue.put(input)
+
+    def read(self, size=None):
+        if size is not None and size < 0:
+            size = None
+        buffer = self.buffer
+        while (self.queue is not None) and ((size is None) or (len(buffer) < size)):
+            if self.queue.empty():
+                self.task.onInputState(True)
+            input = self.queue.get()
+            self.task.onInputState(False)
+            if input is None:  # EOF
+                self.queue = None
+            else:
+                buffer += input
+
+        result = buffer if (size is None) else buffer[:size]
+        self.buffer = buffer[len(result):]
+        return result
+
+    def readline(self, size=None):
+        if size is not None and size < 0:
+            size = None
+        chars = []
+        while (size is None) or (len(chars) < size):
+            c = self.read(1)
+            if not c:
+                break
+            chars.append(c)
+            if c == "\n":
+                break
+
+        return "".join(chars)
+
+
+class ConsoleOutputStream(TextIOBase):
+    """Passes each write to the underlying stream, and also to the given method, which must take
+    a single string argument.
+    """
+    def __init__(self, stream, obj, method_name):
+        self.stream = stream
+        self.method = getattr(obj, method_name)
+
+        try:
+            from java.android.stream import BytesOutputWrapper
+        except ImportError:
+            pass  # Stay compatible with old versions of Chaquopy.
+        else:
+            self.buffer = BytesOutputWrapper(self)
+
+    def __repr__(self):
+        return f"<ConsoleOutputStream {self.stream}>"
+
+    @property
+    def encoding(self):
+        return self.stream.encoding
+
+    @property
+    def errors(self):
+        return self.stream.errors
+
+    def writable(self):
+        return self.stream.writable()
+
+    def write(self, s):
+        # Pass the write to the underlying stream first, so that if it throws an exception, the
+        # app crashes in the same way whether it's using ConsoleOutputStream or not (#5712).
+        result = self.stream.write(s)
+        self.method(s)
+        return result
+
+    def flush(self):
+        self.stream.flush()
diff --git a/app/app/src/utils/python/chaquopy/utils/repl.py b/app/app/src/utils/python/chaquopy/utils/repl.py
new file mode 100644
index 0000000000000000000000000000000000000000..bd1e948bdf698b1016c0e68306bb3b681c4cbf0d
--- /dev/null
+++ b/app/app/src/utils/python/chaquopy/utils/repl.py
@@ -0,0 +1,18 @@
+from code import InteractiveConsole
+import sys
+
+
+class AndroidConsole(InteractiveConsole):
+    """`interact` must be run on a background thread, because it blocks waiting for input.
+    """
+    def __init__(self, context):
+        InteractiveConsole.__init__(self, locals={"context": context.getApplicationContext()})
+
+    def interact(self, banner=None):
+        if banner is None:
+            banner = ("Python {} on {}\n".format(sys.version, sys.platform) +
+                      "The current application context is available in the variable 'context'.")
+        try:
+            InteractiveConsole.interact(self, banner)
+        except SystemExit:
+            pass
diff --git a/app/app/src/utils/res/drawable-hdpi/ic_launcher.png b/app/app/src/utils/res/drawable-hdpi/ic_launcher.png
new file mode 100644
index 0000000000000000000000000000000000000000..de6b168c4e549e63de6ad83ecc7dbf1cd43a1b46
Binary files /dev/null and b/app/app/src/utils/res/drawable-hdpi/ic_launcher.png differ
diff --git a/app/app/src/utils/res/drawable-hdpi/ic_vertical_align_bottom.png b/app/app/src/utils/res/drawable-hdpi/ic_vertical_align_bottom.png
new file mode 100644
index 0000000000000000000000000000000000000000..547b89a83ee66b364d74f5c52d691c748e402b0a
Binary files /dev/null and b/app/app/src/utils/res/drawable-hdpi/ic_vertical_align_bottom.png differ
diff --git a/app/app/src/utils/res/drawable-hdpi/ic_vertical_align_top.png b/app/app/src/utils/res/drawable-hdpi/ic_vertical_align_top.png
new file mode 100644
index 0000000000000000000000000000000000000000..e046e60eff197290d2ddf0a05bab0c59ec06368b
Binary files /dev/null and b/app/app/src/utils/res/drawable-hdpi/ic_vertical_align_top.png differ
diff --git a/app/app/src/utils/res/drawable-mdpi/ic_launcher.png b/app/app/src/utils/res/drawable-mdpi/ic_launcher.png
new file mode 100644
index 0000000000000000000000000000000000000000..e4df75b91dfc65e13f90f61fbdd8b697ee275ec8
Binary files /dev/null and b/app/app/src/utils/res/drawable-mdpi/ic_launcher.png differ
diff --git a/app/app/src/utils/res/drawable-mdpi/ic_vertical_align_bottom.png b/app/app/src/utils/res/drawable-mdpi/ic_vertical_align_bottom.png
new file mode 100644
index 0000000000000000000000000000000000000000..8129d9c84979b13b8c486f5628f87a947ffc6d5f
Binary files /dev/null and b/app/app/src/utils/res/drawable-mdpi/ic_vertical_align_bottom.png differ
diff --git a/app/app/src/utils/res/drawable-mdpi/ic_vertical_align_top.png b/app/app/src/utils/res/drawable-mdpi/ic_vertical_align_top.png
new file mode 100644
index 0000000000000000000000000000000000000000..ee3154d695136cfaa0575028bf98540bca5e5c43
Binary files /dev/null and b/app/app/src/utils/res/drawable-mdpi/ic_vertical_align_top.png differ
diff --git a/app/app/src/utils/res/drawable-xhdpi/ic_launcher.png b/app/app/src/utils/res/drawable-xhdpi/ic_launcher.png
new file mode 100644
index 0000000000000000000000000000000000000000..da669bd1ed126650354d90d10b6dacc9b7a0a3e8
Binary files /dev/null and b/app/app/src/utils/res/drawable-xhdpi/ic_launcher.png differ
diff --git a/app/app/src/utils/res/drawable-xhdpi/ic_vertical_align_bottom.png b/app/app/src/utils/res/drawable-xhdpi/ic_vertical_align_bottom.png
new file mode 100644
index 0000000000000000000000000000000000000000..299f7768ca824216892a55c52429a8474cdf989c
Binary files /dev/null and b/app/app/src/utils/res/drawable-xhdpi/ic_vertical_align_bottom.png differ
diff --git a/app/app/src/utils/res/drawable-xhdpi/ic_vertical_align_top.png b/app/app/src/utils/res/drawable-xhdpi/ic_vertical_align_top.png
new file mode 100644
index 0000000000000000000000000000000000000000..b828e5385a8d71157a742e595e0cf656084332f8
Binary files /dev/null and b/app/app/src/utils/res/drawable-xhdpi/ic_vertical_align_top.png differ
diff --git a/app/app/src/utils/res/drawable-xxhdpi/ic_launcher.png b/app/app/src/utils/res/drawable-xxhdpi/ic_launcher.png
new file mode 100644
index 0000000000000000000000000000000000000000..441d8ce411cef94f3b48de56b3c5873d67d7e1d1
Binary files /dev/null and b/app/app/src/utils/res/drawable-xxhdpi/ic_launcher.png differ
diff --git a/app/app/src/utils/res/drawable-xxhdpi/ic_vertical_align_bottom.png b/app/app/src/utils/res/drawable-xxhdpi/ic_vertical_align_bottom.png
new file mode 100644
index 0000000000000000000000000000000000000000..1972f48ce3f2162b1089a8063410a714ef0a5faa
Binary files /dev/null and b/app/app/src/utils/res/drawable-xxhdpi/ic_vertical_align_bottom.png differ
diff --git a/app/app/src/utils/res/drawable-xxhdpi/ic_vertical_align_top.png b/app/app/src/utils/res/drawable-xxhdpi/ic_vertical_align_top.png
new file mode 100644
index 0000000000000000000000000000000000000000..c3208dd207821a02bff00fae87d26c73f623acd9
Binary files /dev/null and b/app/app/src/utils/res/drawable-xxhdpi/ic_vertical_align_top.png differ
diff --git a/app/app/src/utils/res/layout-land/activity_menu.xml b/app/app/src/utils/res/layout-land/activity_menu.xml
new file mode 100644
index 0000000000000000000000000000000000000000..5c860527b9f2348874a825f3764233c5d7e8b788
--- /dev/null
+++ b/app/app/src/utils/res/layout-land/activity_menu.xml
@@ -0,0 +1,55 @@
+<?xml version="1.0" encoding="utf-8"?>
+<androidx.constraintlayout.widget.ConstraintLayout
+    xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:app="http://schemas.android.com/apk/res-auto"
+    xmlns:tools="http://schemas.android.com/tools"
+    android:layout_width="match_parent"
+    android:layout_height="match_parent"
+    tools:layout_editor_absoluteY="73dp"
+    tools:layout_editor_absoluteX="0dp">
+
+    <TextView
+        android:id="@+id/tvCaption"
+        android:layout_width="0dp"
+        android:layout_height="wrap_content"
+        android:layout_marginRight="8dp"
+        app:layout_constraintRight_toRightOf="parent"
+        android:layout_marginLeft="8dp"
+        app:layout_constraintLeft_toLeftOf="parent"
+        app:layout_constraintTop_toTopOf="parent"
+        android:layout_marginTop="8dp"
+        app:layout_constraintHorizontal_bias="0.0"/>
+
+    <WebView
+        android:id="@+id/wvSource"
+        android:layout_width="0dp"
+        android:layout_height="0dp"
+        android:layout_marginBottom="0dp"
+        android:layout_marginLeft="0dp"
+        android:layout_marginRight="8dp"
+        android:layout_marginTop="8dp"
+        android:visibility="gone"
+        app:layout_constraintBottom_toBottomOf="parent"
+        app:layout_constraintLeft_toRightOf="@+id/flMenu"
+        app:layout_constraintRight_toRightOf="parent"
+        app:layout_constraintTop_toBottomOf="@+id/tvCaption"
+        app:layout_constraintVertical_bias="0.0"
+        app:layout_constraintHorizontal_weight="2"/>
+
+    <FrameLayout
+        android:id="@+id/flMenu"
+        android:layout_width="0dp"
+        android:layout_height="0dp"
+        android:layout_marginBottom="0dp"
+        android:layout_marginLeft="8dp"
+        android:layout_marginRight="8dp"
+        android:layout_marginTop="8dp"
+        app:layout_constraintBottom_toBottomOf="parent"
+        app:layout_constraintLeft_toLeftOf="parent"
+        app:layout_constraintRight_toLeftOf="@+id/wvSource"
+        app:layout_constraintTop_toBottomOf="@+id/tvCaption"
+        app:layout_constraintVertical_bias="1.0"
+        app:layout_constraintHorizontal_weight="1">
+
+    </FrameLayout>
+</androidx.constraintlayout.widget.ConstraintLayout>
diff --git a/app/app/src/utils/res/layout/activity_console.xml b/app/app/src/utils/res/layout/activity_console.xml
new file mode 100644
index 0000000000000000000000000000000000000000..d31a327d792c7be243211dfb0b9eb8a066aa9f8a
--- /dev/null
+++ b/app/app/src/utils/res/layout/activity_console.xml
@@ -0,0 +1,53 @@
+<?xml version="1.0" encoding="utf-8"?>
+<androidx.constraintlayout.widget.ConstraintLayout
+    xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:app="http://schemas.android.com/apk/res-auto"
+    xmlns:tools="http://schemas.android.com/tools"
+    android:layout_width="match_parent"
+    android:layout_height="match_parent"
+    tools:context="com.chaquo.python.utils.ConsoleActivity">
+
+    <EditText
+        android:id="@+id/etInput"
+        android:visibility="gone"
+        android:layout_width="0dp"
+        android:layout_height="wrap_content"
+        android:layout_marginBottom="7dp"
+        android:layout_marginEnd="8dp"
+        android:layout_marginLeft="8dp"
+        android:layout_marginRight="8dp"
+        android:layout_marginStart="8dp"
+        android:ems="10"
+        android:imeOptions="actionDone|flagNoFullscreen"
+        android:singleLine="true"
+        app:layout_constraintBottom_toBottomOf="parent"
+        app:layout_constraintHorizontal_bias="0.0"
+        app:layout_constraintLeft_toLeftOf="parent"
+        app:layout_constraintRight_toRightOf="parent"
+        style="@style/Code">
+        <requestFocus/>
+    </EditText>
+
+    <ScrollView
+        android:id="@+id/svOutput"
+        android:layout_width="0dp"
+        android:layout_height="0dp"
+        android:layout_marginBottom="0dp"
+        android:layout_marginLeft="0dp"
+        android:layout_marginRight="0dp"
+        android:layout_marginTop="0dp"
+        app:layout_constraintBottom_toTopOf="@id/etInput"
+        app:layout_constraintHorizontal_bias="0.0"
+        app:layout_constraintLeft_toLeftOf="parent"
+        app:layout_constraintRight_toRightOf="parent"
+        app:layout_constraintTop_toTopOf="parent"
+        app:layout_constraintVertical_bias="0.0">
+
+        <TextView
+            android:id="@+id/tvOutput"
+            android:layout_width="match_parent"
+            android:layout_height="wrap_content"
+            style="@style/ConsoleOutput"/>
+    </ScrollView>
+
+</androidx.constraintlayout.widget.ConstraintLayout>
diff --git a/app/app/src/utils/res/layout/activity_menu.xml b/app/app/src/utils/res/layout/activity_menu.xml
new file mode 100644
index 0000000000000000000000000000000000000000..10d062070a7922d4b6e647443a9c35281b11598a
--- /dev/null
+++ b/app/app/src/utils/res/layout/activity_menu.xml
@@ -0,0 +1,59 @@
+<?xml version="1.0" encoding="utf-8"?>
+<androidx.constraintlayout.widget.ConstraintLayout
+    xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:app="http://schemas.android.com/apk/res-auto"
+    xmlns:tools="http://schemas.android.com/tools"
+    android:layout_width="match_parent"
+    android:layout_height="match_parent">
+
+    <TextView
+        android:id="@+id/tvCaption"
+        android:layout_width="0dp"
+        android:layout_height="wrap_content"
+        android:layout_marginEnd="8dp"
+        android:layout_marginLeft="8dp"
+        android:layout_marginRight="8dp"
+        android:layout_marginStart="8dp"
+        android:layout_marginTop="8dp"
+        app:layout_constraintLeft_toLeftOf="parent"
+        app:layout_constraintRight_toRightOf="parent"
+        app:layout_constraintTop_toTopOf="parent"
+        app:layout_constraintHorizontal_bias="0.0"/>
+
+    <WebView
+        android:id="@+id/wvSource"
+        android:layout_width="0dp"
+        android:layout_height="0dp"
+        android:layout_marginBottom="0dp"
+        android:layout_marginEnd="8dp"
+        android:layout_marginLeft="0dp"
+        android:layout_marginRight="0dp"
+        android:layout_marginStart="8dp"
+        android:layout_marginTop="8dp"
+        android:visibility="gone"
+        app:layout_constraintBottom_toBottomOf="parent"
+        app:layout_constraintHorizontal_bias="0.0"
+        app:layout_constraintLeft_toLeftOf="parent"
+        app:layout_constraintRight_toRightOf="parent"
+        app:layout_constraintTop_toBottomOf="@+id/flMenu"
+        app:layout_constraintVertical_bias="0.0"/>
+
+    <FrameLayout
+        android:id="@+id/flMenu"
+        android:layout_width="0dp"
+        android:layout_height="0dp"
+        android:layout_marginEnd="8dp"
+        android:layout_marginLeft="8dp"
+        android:layout_marginRight="8dp"
+        android:layout_marginStart="8dp"
+        app:layout_constraintBottom_toTopOf="@id/wvSource"
+        app:layout_constraintHorizontal_bias="0.0"
+        app:layout_constraintLeft_toLeftOf="parent"
+        app:layout_constraintRight_toRightOf="parent"
+        app:layout_constraintTop_toBottomOf="@+id/tvCaption"
+        android:layout_marginTop="8dp"
+        app:layout_constraintVertical_bias="0.0"
+        android:layout_marginBottom="0dp">
+
+    </FrameLayout>
+</androidx.constraintlayout.widget.ConstraintLayout>
diff --git a/app/app/src/utils/res/menu/top_bottom.xml b/app/app/src/utils/res/menu/top_bottom.xml
new file mode 100644
index 0000000000000000000000000000000000000000..babc5538036a4428b2b7992d33eca19638421bf5
--- /dev/null
+++ b/app/app/src/utils/res/menu/top_bottom.xml
@@ -0,0 +1,14 @@
+<menu xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:app="http://schemas.android.com/apk/res-auto">
+
+    <item android:id="@+id/menu_top"
+        android:title="@string/top"
+        android:icon="@drawable/ic_vertical_align_top"
+        app:showAsAction="ifRoom"/>
+
+    <item android:id="@+id/menu_bottom"
+        android:title="@string/bottom"
+        android:icon="@drawable/ic_vertical_align_bottom"
+        app:showAsAction="ifRoom"/>
+
+</menu>
\ No newline at end of file
diff --git a/app/app/src/utils/res/values-sw360dp-v13/support.xml b/app/app/src/utils/res/values-sw360dp-v13/support.xml
new file mode 100644
index 0000000000000000000000000000000000000000..ede5ac1cf39e15098e4b4a33d84ebf9a4c783093
--- /dev/null
+++ b/app/app/src/utils/res/values-sw360dp-v13/support.xml
@@ -0,0 +1,8 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources xmlns:tools="http://schemas.android.com/tools">
+
+    <!-- Remove padding on left (https://stackoverflow.com/a/52960668). -->
+    <bool name="config_materialPreferenceIconSpaceReserved"
+        tools:ignore="MissingDefaultResource,PrivateResource">false</bool>
+
+</resources>
\ No newline at end of file
diff --git a/app/app/src/utils/res/values/console.xml b/app/app/src/utils/res/values/console.xml
new file mode 100644
index 0000000000000000000000000000000000000000..e8943b8b40a82818bbed8f03b6bdbd07915be970
--- /dev/null
+++ b/app/app/src/utils/res/values/console.xml
@@ -0,0 +1,29 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+
+    <string name="repl">Python console</string>
+    <string name="repl_summary">Interactive read-eval-print loop</string>
+
+    <string name="top">Top</string>
+    <string name="bottom">Bottom</string>
+
+    <color name="console_error">#b53f3f</color>
+    <color name="console_meta">#304ffe</color>
+
+    <style name="Code">
+        <item name="android:textSize">14sp</item>
+        <item name="android:typeface">monospace</item>
+
+        <!-- In API level 21 only, the default sans-serif fontFamily takes priority over the
+        `typeface` attribute (https://stackoverflow.com/q/29076599). -->
+        <item name="android:fontFamily">monospace</item>
+    </style>
+
+    <style name="ConsoleOutput" parent="Code">
+        <item name="android:layout_marginLeft">8dp</item>
+        <item name="android:layout_marginRight">8dp</item>
+        <item name="android:textIsSelectable">true</item>
+        <item name="android:freezesText">true</item>
+    </style>
+
+</resources>
diff --git a/app/app/src/utils/res/values/utils.xml b/app/app/src/utils/res/values/utils.xml
new file mode 100644
index 0000000000000000000000000000000000000000..94e92a9569228f884a00ff0f851b469bc69b04d0
--- /dev/null
+++ b/app/app/src/utils/res/values/utils.xml
@@ -0,0 +1,22 @@
+<?xml version="1.0" encoding="utf-8"?>
+<resources>
+
+    <!-- Base application theme. -->
+    <style name="AppTheme" parent="Theme.AppCompat.Light.DarkActionBar">
+        <item name="preferenceTheme">@style/PreferenceThemeOverlay.v14.Material</item>
+
+        <item name="colorPrimary">@color/colorPrimary</item>
+        <item name="colorPrimaryDark">@color/colorPrimaryDark</item>
+        <item name="colorAccent">@color/colorAccent</item>
+    </style>
+
+    <color name="colorPrimary">#3F51B5</color>
+    <color name="colorPrimaryDark">#303F9F</color>
+    <color name="colorAccent">#FF4081</color>
+
+    <string name="main_caption">For more information see the
+        <a href="https://chaquo.com/chaquopy/">Chaquopy website</a>, or view this app\'s source
+        code <a href="https://github.com/chaquo/chaquopy/tree/master/demo">on GitHub</a>.
+    </string>
+
+</resources>
\ No newline at end of file