chromium/chrome/android/webapk/shell_apk/src/org/chromium/webapk/shell_apk/h2o/SplashContentProvider.java

// Copyright 2019 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

package org.chromium.webapk.shell_apk.h2o;

import android.content.ContentProvider;
import android.content.ContentValues;
import android.content.Context;
import android.content.SharedPreferences;
import android.database.Cursor;
import android.graphics.Bitmap;
import android.net.Uri;
import android.os.Bundle;
import android.os.Handler;
import android.os.ParcelFileDescriptor;

import org.chromium.webapk.lib.common.WebApkCommonUtils;
import org.chromium.webapk.shell_apk.WebApkSharedPreferences;

import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.OutputStream;
import java.util.concurrent.atomic.AtomicReference;

/** ContentProvider for screenshot of splash screen. */
public class SplashContentProvider extends ContentProvider
        implements ContentProvider.PipeDataWriter<Void> {
    /** Holds value which gets cleared after {@link ExpiringData#CLEAR_DATA_INTERVAL_MS}. */
    private static class ExpiringData {
        /** Time in milliseconds after constructing the object to clear the cached data. */
        private static final int CLEAR_CACHED_DATA_INTERVAL_MS = 10000;

        private byte[] mCachedData;
        private Handler mHandler;

        public ExpiringData(byte[] cachedData, Runnable expiryTask) {
            mCachedData = cachedData;
            mHandler = new Handler();
            mHandler.postDelayed(expiryTask, CLEAR_CACHED_DATA_INTERVAL_MS);
        }

        public byte[] getCachedData() {
            return mCachedData;
        }

        public void removeCallbacks() {
            mHandler.removeCallbacksAndMessages(null);
        }
    }

    /**
     * Maximum size in bytes of screenshot to transfer to browser. The screenshot should be
     * downsampled to fit. Capping the maximum size of the screenshot decreases bitmap encoding time
     * and image transfer time.
     */
    public static final int MAX_TRANSFER_SIZE_BYTES = 1024 * 1024 * 12;

    /** The encoding type of the last image vended by the ContentProvider. */
    private static Bitmap.CompressFormat sEncodingFormat;

    private static AtomicReference<ExpiringData> sCachedSplashBytes = new AtomicReference<>();

    /** The URI handled by this content provider. */
    private String mContentProviderUri;

    /**
     * Temporarily caches the passed-in splash screen screenshot. To preserve memory, the cached
     * data is cleared after a delay.
     */
    public static void cache(
            Context context,
            byte[] splashBytes,
            Bitmap.CompressFormat encodingFormat,
            int splashWidth,
            int splashHeight) {
        SharedPreferences.Editor editor = WebApkSharedPreferences.getPrefs(context).edit();
        editor.putInt(WebApkSharedPreferences.PREF_SPLASH_WIDTH, splashWidth);
        editor.putInt(WebApkSharedPreferences.PREF_SPLASH_HEIGHT, splashHeight);
        editor.apply();

        sEncodingFormat = encodingFormat;
        getAndSetCachedData(splashBytes);
    }

    public static void clearCache() {
        getAndSetCachedData(null);
    }

    /**
     * Sets the cached splash screen screenshot and returns the old one. Thread safety: Can be
     * called from any thread.
     */
    private static byte[] getAndSetCachedData(byte[] newSplashBytes) {
        ExpiringData newData = null;
        if (newSplashBytes != null) {
            newData = new ExpiringData(newSplashBytes, SplashContentProvider::clearCache);
        }
        ExpiringData oldCachedData = sCachedSplashBytes.getAndSet(newData);
        if (oldCachedData == null) return null;

        oldCachedData.removeCallbacks();
        return oldCachedData.getCachedData();
    }

    @Override
    public boolean onCreate() {
        mContentProviderUri =
                WebApkCommonUtils.generateSplashContentProviderUri(getContext().getPackageName());
        return true;
    }

    @Override
    public ParcelFileDescriptor openFile(Uri uri, String mode) throws FileNotFoundException {
        if (uri != null && uri.toString().equals(mContentProviderUri)) {
            return openPipeHelper(null, null, null, null, this);
        }
        return null;
    }

    @Override
    public void writeDataToPipe(
            ParcelFileDescriptor output, Uri uri, String mimeType, Bundle opts, Void unused) {
        try (OutputStream out = new FileOutputStream(output.getFileDescriptor())) {
            byte[] cachedSplashBytes = getAndSetCachedData(null);
            if (cachedSplashBytes != null) {
                out.write(cachedSplashBytes);
            } else {
                // One way that this case gets hit is when the WebAPK is brought to the foreground
                // via Android Recents after the Android OOM killer has killed the host browser but
                // not SplashActivity.
                Bitmap splashScreenshot = recreateAndScreenshotSplash();
                if (splashScreenshot != null) {
                    sEncodingFormat =
                            SplashUtils.selectBitmapEncoding(
                                    splashScreenshot.getWidth(), splashScreenshot.getHeight());
                    splashScreenshot.compress(sEncodingFormat, 100, out);
                }
            }
            out.flush();
        } catch (Exception e) {
        }
    }

    @Override
    public String getType(Uri uri) {
        if (uri != null && uri.toString().equals(mContentProviderUri)) {
            if (sEncodingFormat == null) {
                Context context = getContext().getApplicationContext();
                SharedPreferences prefs = WebApkSharedPreferences.getPrefs(context);
                int splashWidth = prefs.getInt(WebApkSharedPreferences.PREF_SPLASH_WIDTH, -1);
                int splashHeight = prefs.getInt(WebApkSharedPreferences.PREF_SPLASH_HEIGHT, -1);
                sEncodingFormat = SplashUtils.selectBitmapEncoding(splashWidth, splashHeight);
            }
            if (sEncodingFormat == Bitmap.CompressFormat.PNG) {
                return "image/png";
            } else if (sEncodingFormat == Bitmap.CompressFormat.JPEG) {
                return "image/jpeg";
            }
        }
        return null;
    }

    @Override
    public int update(Uri uri, ContentValues values, String where, String[] whereArgs) {
        throw new UnsupportedOperationException();
    }

    @Override
    public int delete(Uri uri, String selection, String[] selectionArgs) {
        throw new UnsupportedOperationException();
    }

    @Override
    public Uri insert(Uri uri, ContentValues values) {
        throw new UnsupportedOperationException();
    }

    @Override
    public Cursor query(
            Uri uri,
            String[] projection,
            String selection,
            String[] selectionArgs,
            String sortOrder) {
        throw new UnsupportedOperationException();
    }

    /** Builds splashscreen at size that it was last displayed and screenshots it. */
    private Bitmap recreateAndScreenshotSplash() {
        Context context = getContext().getApplicationContext();
        SharedPreferences prefs = WebApkSharedPreferences.getPrefs(context);
        int splashWidth = prefs.getInt(WebApkSharedPreferences.PREF_SPLASH_WIDTH, -1);
        int splashHeight = prefs.getInt(WebApkSharedPreferences.PREF_SPLASH_HEIGHT, -1);
        return SplashUtils.createAndImmediatelyScreenshotSplashView(
                context, splashWidth, splashHeight, MAX_TRANSFER_SIZE_BYTES);
    }
}