diff --git a/app/static/js/app.js b/app/static/js/app.js
index 08cf9553b..dfd298a85 100644
--- a/app/static/js/app.js
+++ b/app/static/js/app.js
@@ -142,6 +142,12 @@ function browserLanguage() {
 
 // Send a keystroke message to the backend, and add a key card to the web UI.
 function sendKeystroke(keystroke) {
+  // On Android, when the user is typing with autocomplete enabled, the browser
+  // sends dummy keydown events with a keycode of 229. Ignore these events, as
+  // there's no way to map it to a real key.
+  if (keystroke.keyCode === 229) {
+    return;
+  }
   let keyCard = undefined;
   if (!keystroke.metaKey) {
     keyCard = addKeyCard(keystroke.key);
diff --git a/app/templates/custom-elements/remote-screen.html b/app/templates/custom-elements/remote-screen.html
index 7ebea4037..07943162c 100644
--- a/app/templates/custom-elements/remote-screen.html
+++ b/app/templates/custom-elements/remote-screen.html
@@ -30,8 +30,14 @@
     :host([fullscreen="true"]) #remote-screen-img.full-height {
       height: 100%;
     }
+
+    #mobile-keyboard-input {
+      position: fixed;
+      bottom: -1000px;
+    }
   </style>
   <div class="screen-wrapper">
+    <input id="mobile-keyboard-input" autocapitalize="off" type="text" />
     <img id="remote-screen-img" src="/stream?advance_headers=1" />
   </div>
   <script
@@ -101,6 +107,40 @@
           });
 
           window.addEventListener("resize", this.onWindowResize);
+
+          // Detect whether this is a touchscreen device.
+          let isTouchScreen = false;
+          this.shadowRoot.addEventListener("touchend", () => {
+            isTouchScreen = true;
+          });
+          this.shadowRoot.addEventListener("click", () => {
+            if (isTouchScreen) {
+              this.shadowRoot.getElementById("mobile-keyboard-input").focus();
+            }
+          });
+
+          // On mobile, the keydown events function differently due to the OS
+          // attempting to autocomplete text. Instead of listening for keydown
+          // events, we listen for input events.
+          const mobileKeyboard = this.shadowRoot.getElementById(
+            "mobile-keyboard-input"
+          );
+          mobileKeyboard.addEventListener("input", (evt) => {
+            // Handle insertCompositionText, which mean typing in autocomplete
+            // mode. The global keydown event handler processes all other key
+            // input events.
+            if (
+              evt.inputType === "insertText" ||
+              evt.inputType === "insertCompositionText"
+            ) {
+              sendTextInput(evt.data);
+            }
+
+            // Force the autocomplete sequence to restart.
+            mobileKeyboard.blur();
+            mobileKeyboard.value = "";
+            mobileKeyboard.focus();
+          });
         }
 
         disconnectedCallback() {
diff --git a/app/templates/index.html b/app/templates/index.html
index c4e656677..00f74eb47 100644
--- a/app/templates/index.html
+++ b/app/templates/index.html
@@ -12,6 +12,10 @@
     <link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
     <link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png" />
     <link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png" />
+    <meta
+      name="viewport"
+      content="width=device-width, initial-scale=1, maximum-scale=1; scalable=no;"
+    />
     <meta name="csrf-token" content="{{ csrf_token() }}" />
   </head>
   <body>