chromium/android_webview/nonembedded/java/src/org/chromium/android_webview/services/VariationsSeedHolder.java

// Copyright 2018 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.android_webview.services;

import android.os.Handler;
import android.os.HandlerThread;
import android.os.ParcelFileDescriptor;

import androidx.annotation.VisibleForTesting;

import org.chromium.android_webview.common.VariationsFastFetchModeUtils;
import org.chromium.android_webview.common.variations.VariationsUtils;
import org.chromium.base.Log;
import org.chromium.base.ResettersForTesting;
import org.chromium.components.variations.firstrun.VariationsSeedFetcher.SeedInfo;

import java.io.File;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.util.Date;
import java.util.Queue;
import java.util.concurrent.ConcurrentLinkedQueue;

/**
 * VariationsSeedHolder is a singleton which manages the local copy of the variations seed - both
 * the file and the SeedInfo object containing the data loaded from that file - in WebView's
 * variations service process. VariationsSeedHolder is used by VariationsSeedServer (to serve the
 * seed to apps) and AwVariationsSeedFetcher (to update the seed). VariationsSeedHolder guards
 * concurrent access to the seed by serializing all operations onto mSeedThread.
 * VariationsSeedHolder is not meant to be used outside the variations service.
 */
public class VariationsSeedHolder {
    private static final String TAG = "VariationsSeedHolder";

    private static class LazyHolder {
        static final VariationsSeedHolder sInstance = new VariationsSeedHolder();
    }

    private static void writeSeedWithoutClosing(SeedInfo seed, ParcelFileDescriptor destination) {
        // writeSeed() will close "out", but closing "out" will not close "destination".
        FileOutputStream out = new FileOutputStream(destination.getFileDescriptor());
        VariationsUtils.writeSeed(out, seed);
    }

    // Use mSeedHandler to send tasks to mSeedThread.
    private final HandlerThread mSeedThread;
    private final Handler mSeedHandler;
    // mSeed is the service's copy of the seed. It should only be used on mSeedThread.
    private SeedInfo mSeed;
    // Set true when we fail to load a seed, to prevent future loads until SeedUpdater runs.
    private boolean mFailedReadingSeed;

    private final SafeModeSeedUpdateNotifier mSafeModeSeedUpdateNotifier =
            new SafeModeSeedUpdateNotifier();
    private Date mFakeDateForTesting;

    // A Runnable which handles an individual request for the seed. Must run on mSeedThread.
    private class SeedWriter implements Runnable {
        private ParcelFileDescriptor mDestination;

        // mDestinationDate is the date field of the requester's current seed, in milliseconds since
        // epoch, or Long.MIN_VALUE if the requester has no seed. Only write our seed if our seed is
        // newer than mDestinationDate.
        private long mDestinationDate;

        public SeedWriter(ParcelFileDescriptor destination, long date) {
            mDestination = destination;
            mDestinationDate = date;
        }

        @Override
        public void run() {
            assert Thread.currentThread() == mSeedThread;

            try {
                scheduleFetchIfNeeded();

                // Load the seed if necessary.
                if (VariationsSeedHolder.this.mSeed == null && !mFailedReadingSeed) {
                    VariationsSeedHolder.this.mSeed =
                            VariationsUtils.readSeedFile(VariationsUtils.getSeedFile());
                    mFailedReadingSeed = VariationsSeedHolder.this.mSeed == null;
                }

                // If there's no seed available, the app will have to request again.
                if (VariationsSeedHolder.this.mSeed == null) return;

                if (mDestinationDate < VariationsSeedHolder.this.mSeed.date) {
                    VariationsUtils.debugLog("Writing new seed to app's data directory");
                    writeSeedWithoutClosing(VariationsSeedHolder.this.mSeed, mDestination);
                }
            } finally {
                VariationsUtils.closeSafely(mDestination);
                onWriteFinished();
            }
        }
    }

    // A Runnable which updates both mSeed and the service's seed file. Must run on mSeedThread.
    private class SeedUpdater implements Runnable {
        private SeedInfo mNewSeed;
        private Runnable mOnFinished;

        public SeedUpdater(SeedInfo newSeed, Runnable onFinished) {
            mNewSeed = newSeed;
            mOnFinished = onFinished;
        }

        @Override
        public void run() {
            assert Thread.currentThread() == mSeedThread;
            try {
                VariationsSeedHolder.this.mSeed = mNewSeed;

                // Update the seed file.
                File newSeedFile = VariationsUtils.getNewSeedFile();
                FileOutputStream out;
                try {
                    out = new FileOutputStream(newSeedFile);
                } catch (FileNotFoundException e) {
                    Log.e(TAG, "Failed to open seed file " + newSeedFile + " for update");
                    return;
                }
                if (!VariationsUtils.writeSeed(out, VariationsSeedHolder.this.mSeed)) {
                    Log.e(TAG, "Failed to write seed file " + newSeedFile + " for update");
                    return;
                }
                VariationsUtils.replaceOldWithNewSeed();
                mFailedReadingSeed = false;
                mSafeModeSeedUpdateNotifier.reportSeedUpdateCompletion();
            } finally {
                mOnFinished.run();
            }
        }
    }

    @VisibleForTesting
    protected VariationsSeedHolder() {
        mSeedThread = new HandlerThread(/* name= */ "seed_holder");
        mSeedThread.start();
        mSeedHandler = new Handler(mSeedThread.getLooper());
    }

    public static VariationsSeedHolder getInstance() {
        return LazyHolder.sInstance;
    }

    public void writeSeedIfNewer(ParcelFileDescriptor destination, long date) {
        mSeedHandler.post(new SeedWriter(destination, date));
    }

    public void updateSeed(SeedInfo newSeed, Runnable onFinished) {
        mSeedHandler.post(new SeedUpdater(newSeed, onFinished));
    }

    @VisibleForTesting
    public void scheduleFetchIfNeeded() {
        AwVariationsSeedFetcher.scheduleIfNeeded();
    }

    // overridden by tests
    public void onWriteFinished() {}

    /**
     * A seed updater class tailored to notify that Variations seeds have been updated specifically
     * for SafeMode scenarios
     */
    private class SafeModeSeedUpdateNotifier {
        // Stores a list of requests to notify the requester, SafeModeVariationsSeedContentProvider,
        // as soon as the Variations Fast Fetch Mode seed is fresh, where fresh is considered to be
        // < 15 minutes old.
        private final Queue<Runnable> mSafeModeVariationsSeedContentProviderCallback =
                new ConcurrentLinkedQueue<Runnable>();

        public void hasSeedUpdateCompletedAsync(Runnable r) {
            mSafeModeVariationsSeedContentProviderCallback.add(r);
            if (isSeedFileFresh()) {
                reportSeedUpdateCompletion();
            }
        }

        public void reportSeedUpdateCompletion() {
            Runnable r;
            while ((r = mSafeModeVariationsSeedContentProviderCallback.poll()) != null) {
                r.run();
            }
        }
    }

    public void hasSeedUpdateCompletedAsync(Runnable r) {
        mSafeModeSeedUpdateNotifier.hasSeedUpdateCompletedAsync(r);
    }

    @VisibleForTesting
    public boolean isSeedFileFresh() {
        long currTimestamp = getCurrentTimestamp();
        long lastModified = VariationsUtils.getSeedFile().lastModified();
        return (lastModified > 0
                && (currTimestamp - lastModified)
                        < VariationsFastFetchModeUtils.MAX_ALLOWABLE_SEED_AGE_MS);
    }

    public void setDateForTesting(Date date) {
        var oldValue = mFakeDateForTesting;
        mFakeDateForTesting = date;
        ResettersForTesting.register(() -> mFakeDateForTesting = oldValue);
    }

    private long getCurrentTimestamp() {
        return mFakeDateForTesting != null ? mFakeDateForTesting.getTime() : new Date().getTime();
    }
}