This app demonstrates how to build a web app using web technologies
(Javascript, HTML, CSS) that is presented as a mobile web app running in
Android's WebView
.
This app uses Android JetPack Compose.
This example Android app demonstrates:
- The setup of a mobile web app provided through
WebView
using Jetpack Compose. - Exposing a native Android API that is callable from the web app
- Exposing a Javascript API that is callable from native Android code.
- A server-client message passing protocol between the web app and the native Android code allowing for asynchronous communication.
- Managing
WebView
state in the presence of the Android Lifecycle.
All example code is well documented. Begin by exploring
com.bitwisearts.example.MainActivity
.
The web app is a plain HTML and Javascript application placed in
app/src/main/assets
. It performs all the necessary
tasks to support this demo; it is not meant to be the magnus opus of web
applications.
Note that the Javascript is delivered as a single script file and does not
utilize modern modules to better organize the application. Using modules is
not permitted
when accessing a web application from the file system using file:///
.
The native Android portion is developed to support a clear example of a viable way to deliver a hybrid native Android - mobile web app. No effort was put forth on themes nor any real work on UI/UX other than what is needed to demonstrate a way to deliver a hybrid app.
The native API exposed to Javascript is located in
ExampleNativeAndroidAPI
.
It utilizes coroutines to execute asynchronous code. Note that all calls to execute Javascript code from native Android must be done on the UI thread.
This example presumes the viewer understands how to setup an Android project. It is possible to start with Android Studio's new project wizard, choosing to create a new Jetpack Compose project.
Much of the project setup involving the AndroidManifest
file relates to things such as user-permission
. It is presumed that those
viewing this example are familiar with the Android manifest file.
Some comments were added in the manifest file, but the aim of this example is
not to instruct viewers on the ins and outs of this file.
Android Studio generates projects using Groovy Gradle files. It is my preference to use the Gradle Kotlin DSL. This is a personal preference as I have over 5 years experience writing Kotlin while my Groovy experience is limited to Gradle file manipulation over the years before Gradle Kotlin DSL was available. I always transform generated Groovy Gradle files to Kotlin DSL at the start of every project, however, this is not necessary. If you are more comfortable with Groovy, stick with Groovy for Gradle.
There are various permissions
that require setup in a uses-permission
tag in the AndroidManifest
file. The
permission requirements
will vary for your Android app depending on the Android resources used.
See the AndroidManifest
file to view
permissions used by this example application.
Additionally, some permissions must be granted by the user. Requesting this
permission is handled by the Composable
function,
ConditionallyRequestPermission
. This function is defined at the bottom of
MainActivity
.
If the state is not actively preserved, the WebView
will be reset to the start
page on events such as a screen rotation or a change in the size of the screen.
A LifecycleEventObserver
is added to manage the saving of WebView
state. This
can be found in CustomWebView.
Using a mix of Android Bundle
and Window.localStorage
we can preserve state on both sides during lifecycle events.
The web app uses the class, AppDataContext
,
to manage the web application state that must be persisted.
WARNING Data stored in Window.localStorage
persists between runs of the
app so special care needs to be taken to clean up localStorage
when the data
is no longer needed. There is no lifecycle event in Android that can be used to
trigger clearing Window.localStorage
when the app closes. To accomplish this
the application clears app data from Window.localStorage
at app startup.
Review the function, checkFreshStart()
in app.js
to trace how this works.
It should be noted that the web app doesn't use a framework which provides more flexibility in how it manages its own state, but it does require more work to manage said state. State management considerations would have to be made if the web app were built using a framework like Angular or React.
Communication between the native Android code and the web code in the WebView
is fairly limited. The JavascriptInterface
annotation provides a kind of
foreign function interface that allows the web app to make native Android
function calls from Javascript. The WebView
allows for the evaluation of
arbitrary Javascript
using WebView.evaluateJavascript
.
This is limited in that there is no way natively to preserve callbacks on either side that can be triggered asynchronously when the API being called must process something asynchronously before returning it (open a file, or scan a barcode). Because of this, we have to effectively model communication as we would using bidirectional communication over a socket (think server-client over WebSockets). This has to be designed so that requests being processed asynchronously survive lifecycle events. (see Preserve State)
The web app manages asynchronous requests through AndroidAPIRequests
that are stored in AppDataContext.conversations
. See ApiMessages
to see how the native Android code handles asynchronous API requests from the
web app.
Another approach not used in this example involves using MessagePort
.
Android introduced WebMessagePort
in API 23. Using this would be akin to using WebSockets in a server-client
architecture where the Android native would act as the server and the web app in
the WebView
as the client. A notable limitation here is that WebMessagePort
can only pass string data (no binary support). Cursory research didn't turn up
anything of note in terms of WebMessagePort
usage in Android.
Realistically navigation can be handled in one of three of ways:
- Use a
NavHostController
in a call ofNavHost
to enable jump navigation betweenComposables
, each one its own tree of possible recompositions to deliver a UI based on the view state. - Use a single root
Composable
with a global view data object that manages what is expected to be displayed. This effectively takes the disparate views from option 1 and places them all under the sameComposable
root. You still need aNavHostController
to create a customBackHandler
for navigating the appropriate back stack (WebView.goBack
vsNavHostController.popBackStack
). This simplifies state management. - A hybrid approach of options #1 & #2.
The challenge with navigation is preserving WebView
state. Some Android
utilities, such as the camera or a barcode scanner, depending on how they are
launched, may cause the WebView
to be destroyed.
For this example, we use option 1 from above. We use the Application
subclass
WebApp
to manage
async message state in the field, WebApp.responseQueue
. The comment for this
field indicates better solutions than this, but for this example, it is "good
enough".
There are two tools used to debug an Android application that uses a WebView
,
Android Studio and the Chrome web browser.
It is presumed you are familiar with standard Android development and the usage of the debugger in Android Studio.
The Chrome web browser has debugging tools
that can be used to debug your web app running in the WebView
. To do this:
- Connect your Android device to your computer
- Start the Android app
- Open a Chrome web browser
- Navigate to chrome://inspect/#devices
- Click the
inspect
link under your device to open the standard Chrome development tools.
Malformed Javascript calls from native Android code using
WebView.evaluateJavascript
will be reported differently. Logged web messages
that are captured by a custom WebChromeClient
and logged using Logcat are useless.
They are reported as:
E/WebViewConsole: (file:///android_asset/index.html: ERROR) Line number: 1
Uncaught SyntaxError: Invalid or unexpected token
where index.html
is the file being displayed in the WebView
. The line number
provided is actually the line number in the evaluated Javascript that failed. So
evaluating the following script will report the error on line 5:
WebView.evaluateJavascript("\n\n\nstringConcat(\n\t'Dvdb', 'v;")
The Chrome debug console is where you want to debug this. The Javascript being
run using WebView.evaluateJavascript
is executed in a VM (Virtual Machine).
The engine doesn't know where the script came from, so it assigns it a number
and prefixes it with VM
. Clicking on the error in the console opens up a
window showing the exact script that failed to execute.