Is it possible to use WebView in Worker?

Refresh

April 2019

Views

295 time

1

Background

I'm trying to load some URL in the background, but in the same way WebView loads it in an Activity.

There are multiple reasons developers would want it (and requested about it here) , such as running JavaScript without Activity, caching, monitor websites changes, scrapping ...

The problem

It seems that on some devices and Android versions (Pixel 2 with Android P, for example), this works fine on Worker , but on some others (probably on older versions of Android), I can do it well and safely only on a foreground service with on-top view using the SYSTEM_ALERT_WINDOW permission.

Thing is, we need to use it in the background, as we have a Worker already that is intended for other things. We would prefer not to add a foreground service just for that, as it would make things complex, add a required permission, and would make a notification for the user as long as it needs to do the work.

What I've tried&found

  1. Searching the Internet, I can find only few mention this scenario (here and here). The main solution is indeed to have a foreground service with on-top view.

  2. In order to check if the website loads fine, I've added logs in various callbacks, including onProgressChanged , onConsoleMessage, onReceivedError , onPageFinished , shouldInterceptRequest, onPageStarted . All part of WebViewClient and WebChromeClient classes.

I've tested on websites that I know should write to the console, a bit complex and take some time to load, such as Reddit and Imgur .

  1. It is important to let JavaScript enabled, as we might need to use it, and websites load as they should when it's enabled, so I've set javaScriptEnabled=true . I've noticed there is also javaScriptCanOpenWindowsAutomatically , but as I've read this isn't usually needed, so I didn't really use it. Plus it seems that enabling it causes my solutions (on Worker) to fail more, but maybe it's just a coincidence . Also, it's important to know that WebView should be used on the UI thread, so I've put its handling on a Handler that is associated with the UI thread.

  2. I've tried to enable more flags in WebSettings class of the WebView, and I also tried to emulate that it's inside of a container, by measuring it.

  3. Tried to delay the loading a bit, and tried to load an empty URL first. On some cases it seemed to help, but it's not consistent .

Doesn't seem like anything helped, but on some random cases various solutions seemed to work nevertheless (but not consistent).

Here's my current code, which also includes some of what I've tried (project available here) :

Util.kt

object Util {
    @SuppressLint("SetJavaScriptEnabled")
    @UiThread
    fun getNewWebView(context: Context): WebView {
        val webView = WebView(context)
//        val screenWidth = context.resources.displayMetrics.widthPixels
//        val screenHeight = context.resources.displayMetrics.heightPixels
//        webView.measure(screenWidth, screenHeight)
//        webView.layout(0, 0, screenWidth, screenHeight)
//        webView.measure(600, 400);
//        webView.layout(0, 0, 600, 400);
        val webSettings = webView.settings
        webSettings.javaScriptEnabled = true
//        webSettings.loadWithOverviewMode = true
//        webSettings.useWideViewPort = true
//        webSettings.javaScriptCanOpenWindowsAutomatically = true
//        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) {
//            webSettings.allowFileAccessFromFileURLs = true
//            webSettings.allowUniversalAccessFromFileURLs = true
//        }
        webView.webChromeClient = object : WebChromeClient() {
            override fun onProgressChanged(view: WebView?, newProgress: Int) {
                super.onProgressChanged(view, newProgress)
                Log.d("appLog", "onProgressChanged:$newProgress " + view?.url)
            }

            override fun onConsoleMessage(consoleMessage: ConsoleMessage?): Boolean {
                if (consoleMessage != null)
                    Log.d("appLog", "webViewConsole:" + consoleMessage.message())
                return super.onConsoleMessage(consoleMessage)
            }
        }
        webView.webViewClient = object : WebViewClient() {

            override fun onReceivedError(view: WebView, request: WebResourceRequest, error: WebResourceError) {
                Log.d("appLog", "error $request  $error")
            }

            override fun onPageFinished(view: WebView?, url: String?) {
                super.onPageFinished(view, url)
                Log.d("appLog", "onPageFinished:$url")
            }

            override fun shouldInterceptRequest(view: WebView, request: WebResourceRequest): WebResourceResponse? {
                if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP)
                    Log.d("appLog", "shouldInterceptRequest:${request.url}")
                else
                    Log.d("appLog", "shouldInterceptRequest")
                return super.shouldInterceptRequest(view, request)
            }

            override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) {
                super.onPageStarted(view, url, favicon)
                Log.d("appLog", "onPageStarted:$url hasFavIcon?${favicon != null}")
            }

        }
        return webView
    }


    @TargetApi(Build.VERSION_CODES.M)
    fun isSystemAlertPermissionGranted(@NonNull context: Context): Boolean {
        return Build.VERSION.SDK_INT <= Build.VERSION_CODES.LOLLIPOP_MR1 || Settings.canDrawOverlays(context)
    }

    fun requestSystemAlertPermission(context: Activity?, fragment: Fragment?, requestCode: Int) {
        if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.LOLLIPOP_MR1)
            return
        //http://developer.android.com/reference/android/Manifest.permission.html#SYSTEM_ALERT_WINDOW
        val packageName = if (context == null) fragment!!.activity!!.packageName else context.packageName
        var intent = Intent(Settings.ACTION_MANAGE_OVERLAY_PERMISSION, Uri.parse("package:$packageName"))
        try {
            if (fragment != null)
                fragment.startActivityForResult(intent, requestCode)
            else
                context!!.startActivityForResult(intent, requestCode)
        } catch (e: Exception) {
            intent = Intent(Settings.ACTION_MANAGE_APPLICATIONS_SETTINGS)
            if (fragment != null)
                fragment.startActivityForResult(intent, requestCode)
            else
                context!!.startActivityForResult(intent, requestCode)
        }
    }

    /**
     * requests (if needed) system alert permission. returns true iff requested.
     * WARNING: You should always consider checking the result of this function
     */
    fun requestSystemAlertPermissionIfNeeded(activity: Activity?, fragment: Fragment?, requestCode: Int): Boolean {
        val context = activity ?: fragment!!.activity
        if (isSystemAlertPermissionGranted(context!!))
            return false
        requestSystemAlertPermission(activity, fragment, requestCode)
        return true
    }
}

MyService.kt

class MyService : Service() {

    override fun onBind(intent: Intent): IBinder? = null
    override fun onCreate() {
        super.onCreate()
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
            val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
            run {
                //general
                val channel = NotificationChannel("channel_id__general", "channel_name__general", NotificationManager.IMPORTANCE_DEFAULT)
                channel.enableLights(false)
                channel.setSound(null, null)
                notificationManager.createNotificationChannel(channel)
            }
        }
        val builder = NotificationCompat.Builder(this, "channel_id__general")
        builder.setSmallIcon(android.R.drawable.sym_def_app_icon).setContentTitle(getString(R.string.app_name))
        startForeground(1, builder.build())
    }

    @SuppressLint("SetJavaScriptEnabled")
    override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
        val windowManager = getSystemService(Context.WINDOW_SERVICE) as WindowManager
        val params = WindowManager.LayoutParams(
                android.view.ViewGroup.LayoutParams.WRAP_CONTENT,
                android.view.ViewGroup.LayoutParams.WRAP_CONTENT,
                if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) WindowManager.LayoutParams.TYPE_APPLICATION_OVERLAY else WindowManager.LayoutParams.TYPE_PHONE,
                WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE,
                PixelFormat.TRANSLUCENT
        )
        params.gravity = Gravity.TOP or Gravity.START
        params.x = 0
        params.y = 0
        params.width = 0
        params.height = 0
        val webView = Util.getNewWebView(this)
//        webView.loadUrl("https://www.google.com/")
//        webView.loadUrl("https://www.google.com/")
//        webView.loadUrl("")
//        Handler().postDelayed( {
//        webView.loadUrl("")
        webView.loadUrl("https://imgur.com/a/GPlx4?desktop=1")
//        },5000L)
//        webView.loadUrl("https://imgur.com/a/GPlx4?desktop=1")
        windowManager.addView(webView, params)
        return super.onStartCommand(intent, flags, startId)
    }

}

MainActivity.kt

class MainActivity : AppCompatActivity() {

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContentView(R.layout.activity_main)
        startServiceButton.setOnClickListener {
            if (!Util.requestSystemAlertPermissionIfNeeded(this, null, REQUEST_DRAW_ON_TOP))
                ContextCompat.startForegroundService([email protected], Intent([email protected], MyService::class.java))
        }
        startWorkerButton.setOnClickListener {
            val workManager = WorkManager.getInstance()
            workManager.cancelAllWorkByTag(WORK_TAG)
            val builder = OneTimeWorkRequest.Builder(BackgroundWorker::class.java).addTag(WORK_TAG)
            builder.setConstraints(Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED)
                    .setRequiresCharging(false).build())
            builder.setInitialDelay(5, TimeUnit.SECONDS)
            workManager.enqueue(builder.build())
        }
    }

    override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
        super.onActivityResult(requestCode, resultCode, data)
        if (requestCode == REQUEST_DRAW_ON_TOP && Util.isSystemAlertPermissionGranted(this))
            ContextCompat.startForegroundService([email protected], Intent([email protected], MyService::class.java))
    }


    class BackgroundWorker : Worker() {
        val handler = Handler(Looper.getMainLooper())
        override fun doWork(): Result {
            Log.d("appLog", "doWork started")
            handler.post {
                val webView = Util.getNewWebView(applicationContext)
//        webView.loadUrl("https://www.google.com/")
        webView.loadUrl("https://www.google.com/")
//                webView.loadUrl("")
//                Handler().postDelayed({
//                    //                webView.loadUrl("")
////                    webView.loadUrl("https://imgur.com/a/GPlx4?desktop=1")
//                    webView.loadUrl("https://www.reddit.com/")
//
//                }, 1000L)
//        webView.loadUrl("https://imgur.com/a/GPlx4?desktop=1")
            }
            Thread.sleep(20000L)
            Log.d("appLog", "doWork finished")
            return Worker.Result.SUCCESS
        }
    }

    companion object {
        const val REQUEST_DRAW_ON_TOP = 1
        const val WORK_TAG = "WORK_TAG"
    }
}

activity_main.xml

<LinearLayout
    xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent" android:layout_height="match_parent" android:gravity="center"
    android:orientation="vertical" tools:context=".MainActivity">

    <Button
        android:id="@+id/startServiceButton" android:layout_width="wrap_content" android:layout_height="wrap_content"
        android:text="start service"/>


    <Button
        android:id="@+id/startWorkerButton" android:layout_width="wrap_content" android:layout_height="wrap_content"
        android:text="start worker"/>
</LinearLayout>

gradle file

...
dependencies {
    implementation fileTree(dir: 'libs', include: ['*.jar'])
    implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
    implementation 'androidx.appcompat:appcompat:1.0.0-rc02'
    implementation 'androidx.core:core-ktx:1.0.0-rc02'
    implementation 'androidx.constraintlayout:constraintlayout:1.1.2'
    def work_version = "1.0.0-alpha08"
    implementation "android.arch.work:work-runtime-ktx:$work_version"
    implementation "android.arch.work:work-firebase:$work_version"
}

manifest

<manifest package="com.example.webviewinbackgroundtest" xmlns:android="http://schemas.android.com/apk/res/android"
          xmlns:tools="http://schemas.android.com/tools">

    <uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
    <uses-permission android:name="android.permission.INTERNET"/>
    <uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW"/>

    <application
        android:allowBackup="true" android:icon="@mipmap/ic_launcher" android:label="@string/app_name"
        android:roundIcon="@mipmap/ic_launcher_round" android:supportsRtl="true" android:theme="@style/AppTheme"
        tools:ignore="AllowBackup,GoogleAppIndexingWarning">
        <activity android:name=".MainActivity">
            <intent-filter>
                <action android:name="android.intent.action.MAIN"/>

                <category android:name="android.intent.category.LAUNCHER"/>
            </intent-filter>
        </activity>

        <service
            android:name=".MyService" android:enabled="true" android:exported="true"/>
    </application>

</manifest>

The questions

  1. Main question: Is it even possible to use a WebView within Worker?

  2. How come it seems to work fine on Android P in a Worker, but not on others?

  3. How come sometimes it did work on a Worker?

  4. Is there an alternative, either to do it in Worker, or having an alternative to WebView that is capable of the same operations of loading webpages and running Javascripts on them ?

1 answers

0

I think we need another tool for these kind of scenarios. My honest opinion is, it's a WebView, a view after all, which is designed to display web pages. I know as we need to implement hacky solutions to resolve such cases, but I believe these are not webView concerns either.

What I think would be the solution is, instead of observing web page and listening javaScripts for changes, changes should be delivered to app by a proper message ( push / socket / web service ).

If it's not possible to do it this way, I believe request (https://issuetracker.google.com/issues/113346931) should not be "being able to run WebView in a service" but a proper addition to SDK which would perform operations you mentioned.

Mel