/*
 * Copyright (C) 2018 The Android Open Source Project
 *
 * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file
 * except in compliance with the License. You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software distributed under the
 * License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
 * KIND, either express or implied. See the License for the specific language governing
 * permissions and limitations under the License.
 */

package com.android.server.slice;

import android.content.ContentProvider;
import android.content.Context;
import android.net.Uri;
import android.os.Environment;
import android.os.Handler;
import android.os.Looper;
import android.os.Message;
import android.text.format.DateUtils;
import android.util.ArrayMap;
import android.util.ArraySet;
import android.util.AtomicFile;
import android.util.Log;
import android.util.Slog;
import android.util.Xml.Encoding;

import com.android.internal.annotations.VisibleForTesting;
import com.android.internal.util.XmlUtils;
import com.android.server.slice.SliceProviderPermissions.SliceAuthority;

import org.xmlpull.v1.XmlPullParser;
import org.xmlpull.v1.XmlPullParserException;
import org.xmlpull.v1.XmlPullParserFactory;
import org.xmlpull.v1.XmlSerializer;

import java.io.File;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.util.Objects;

public class SlicePermissionManager implements DirtyTracker {

    private static final String TAG = "SlicePermissionManager";

    /**
     * The amount of time we'll cache a SliceProviderPermissions or SliceClientPermissions
     * in case they are used again.
     */
    private static final long PERMISSION_CACHE_PERIOD = 5 * DateUtils.MINUTE_IN_MILLIS;

    /**
     * The amount of time we delay flushing out permission changes to disk because they usually
     * come in short bursts.
     */
    private static final long WRITE_GRACE_PERIOD = 500;

    private static final String SLICE_DIR = "slice";

    // If/when this bumps again we'll need to write it out in the disk somewhere.
    // Currently we don't have a central file for this in version 2 and there is no
    // reason to add one until we actually have incompatible version bumps.
    // This does however block us from reading backups from P-DP1 which may contain
    // a very different XML format for perms.
    static final int DB_VERSION = 2;

    private static final String TAG_LIST = "slice-access-list";
    private final String ATT_VERSION = "version";

    private final File mSliceDir;
    private final Context mContext;
    private final Handler mHandler;
    private final ArrayMap<PkgUser, SliceProviderPermissions> mCachedProviders = new ArrayMap<>();
    private final ArrayMap<PkgUser, SliceClientPermissions> mCachedClients = new ArrayMap<>();
    private final ArraySet<Persistable> mDirty = new ArraySet<>();

    @VisibleForTesting
    SlicePermissionManager(Context context, Looper looper, File sliceDir) {
        mContext = context;
        mHandler = new H(looper);
        mSliceDir = sliceDir;
    }

    public SlicePermissionManager(Context context, Looper looper) {
        this(context, looper, new File(Environment.getDataDirectory(), "system/" + SLICE_DIR));
    }

    public void grantFullAccess(String pkg, int userId) {
        PkgUser pkgUser = new PkgUser(pkg, userId);
        SliceClientPermissions client = getClient(pkgUser);
        client.setHasFullAccess(true);
    }

    public void grantSliceAccess(String pkg, int userId, String providerPkg, int providerUser,
            Uri uri) {
        PkgUser pkgUser = new PkgUser(pkg, userId);
        PkgUser providerPkgUser = new PkgUser(providerPkg, providerUser);

        SliceClientPermissions client = getClient(pkgUser);
        client.grantUri(uri, providerPkgUser);

        SliceProviderPermissions provider = getProvider(providerPkgUser);
        provider.getOrCreateAuthority(ContentProvider.getUriWithoutUserId(uri).getAuthority())
                .addPkg(pkgUser);
    }

    public void revokeSliceAccess(String pkg, int userId, String providerPkg, int providerUser,
            Uri uri) {
        PkgUser pkgUser = new PkgUser(pkg, userId);
        PkgUser providerPkgUser = new PkgUser(providerPkg, providerUser);

        SliceClientPermissions client = getClient(pkgUser);
        client.revokeUri(uri, providerPkgUser);
    }

    public void removePkg(String pkg, int userId) {
        PkgUser pkgUser = new PkgUser(pkg, userId);
        SliceProviderPermissions provider = getProvider(pkgUser);

        for (SliceAuthority authority : provider.getAuthorities()) {
            for (PkgUser p : authority.getPkgs()) {
                getClient(p).removeAuthority(authority.getAuthority(), userId);
            }
        }
        SliceClientPermissions client = getClient(pkgUser);
        client.clear();
        mHandler.obtainMessage(H.MSG_REMOVE, pkgUser).sendToTarget();
    }

    public String[] getAllPackagesGranted(String pkg) {
        ArraySet<String> ret = new ArraySet<>();
        for (SliceAuthority authority : getProvider(new PkgUser(pkg, 0)).getAuthorities()) {
            for (PkgUser pkgUser : authority.getPkgs()) {
                ret.add(pkgUser.mPkg);
            }
        }
        return ret.toArray(new String[ret.size()]);
    }

    public boolean hasFullAccess(String pkg, int userId) {
        PkgUser pkgUser = new PkgUser(pkg, userId);
        return getClient(pkgUser).hasFullAccess();
    }

    public boolean hasPermission(String pkg, int userId, Uri uri) {
        PkgUser pkgUser = new PkgUser(pkg, userId);
        SliceClientPermissions client = getClient(pkgUser);
        int providerUserId = ContentProvider.getUserIdFromUri(uri, userId);
        return client.hasFullAccess()
                || client.hasPermission(ContentProvider.getUriWithoutUserId(uri), providerUserId);
    }

    @Override
    public void onPersistableDirty(Persistable obj) {
        mHandler.removeMessages(H.MSG_PERSIST);
        mHandler.obtainMessage(H.MSG_ADD_DIRTY, obj).sendToTarget();
        mHandler.sendEmptyMessageDelayed(H.MSG_PERSIST, WRITE_GRACE_PERIOD);
    }

    public void writeBackup(XmlSerializer out) throws IOException, XmlPullParserException {
        synchronized (this) {
            out.startTag(null, TAG_LIST);
            out.attribute(null, ATT_VERSION, String.valueOf(DB_VERSION));

            // Don't do anything with changes from the backup, because there shouldn't be any.
            DirtyTracker tracker = obj -> { };
            if (mHandler.hasMessages(H.MSG_PERSIST)) {
                mHandler.removeMessages(H.MSG_PERSIST);
                handlePersist();
            }
            for (String file : new File(mSliceDir.getAbsolutePath()).list()) {
                try (ParserHolder parser = getParser(file)) {
                    Persistable p = null;
                    while (parser.parser.getEventType() != XmlPullParser.END_DOCUMENT) {
                        if (parser.parser.getEventType() == XmlPullParser.START_TAG) {
                            if (SliceClientPermissions.TAG_CLIENT.equals(parser.parser.getName())) {
                                p = SliceClientPermissions.createFrom(parser.parser, tracker);
                            } else {
                                p = SliceProviderPermissions.createFrom(parser.parser, tracker);
                            }
                            break;
                        }
                        parser.parser.next();
                    }
                    if (p != null) {
                        p.writeTo(out);
                    } else {
                        Slog.w(TAG, "Invalid or empty slice permissions file: " + file);
                    }
                }
            }

            out.endTag(null, TAG_LIST);
        }
    }

    public void readRestore(XmlPullParser parser) throws IOException, XmlPullParserException {
        synchronized (this) {
            while ((parser.getEventType() != XmlPullParser.START_TAG
                    || !TAG_LIST.equals(parser.getName()))
                    && parser.getEventType() != XmlPullParser.END_DOCUMENT) {
                parser.next();
            }
            int xmlVersion = XmlUtils.readIntAttribute(parser, ATT_VERSION, 0);
            if (xmlVersion < DB_VERSION) {
                // No conversion support right now.
                return;
            }
            while (parser.getEventType() != XmlPullParser.END_DOCUMENT) {
                if (parser.getEventType() == XmlPullParser.START_TAG) {
                    if (SliceClientPermissions.TAG_CLIENT.equals(parser.getName())) {
                        SliceClientPermissions client = SliceClientPermissions.createFrom(parser,
                                this);
                        synchronized (mCachedClients) {
                            mCachedClients.put(client.getPkg(), client);
                        }
                        onPersistableDirty(client);
                        mHandler.sendMessageDelayed(
                                mHandler.obtainMessage(H.MSG_CLEAR_CLIENT, client.getPkg()),
                                PERMISSION_CACHE_PERIOD);
                    } else if (SliceProviderPermissions.TAG_PROVIDER.equals(parser.getName())) {
                        SliceProviderPermissions provider = SliceProviderPermissions.createFrom(
                                parser, this);
                        synchronized (mCachedProviders) {
                            mCachedProviders.put(provider.getPkg(), provider);
                        }
                        onPersistableDirty(provider);
                        mHandler.sendMessageDelayed(
                                mHandler.obtainMessage(H.MSG_CLEAR_PROVIDER, provider.getPkg()),
                                PERMISSION_CACHE_PERIOD);
                    } else {
                        parser.next();
                    }
                } else {
                    parser.next();
                }
            }
        }
    }

    private SliceClientPermissions getClient(PkgUser pkgUser) {
        SliceClientPermissions client;
        synchronized (mCachedClients) {
            client = mCachedClients.get(pkgUser);
        }
        if (client == null) {
            try (ParserHolder parser = getParser(SliceClientPermissions.getFileName(pkgUser))) {
                client = SliceClientPermissions.createFrom(parser.parser, this);
                synchronized (mCachedClients) {
                    mCachedClients.put(pkgUser, client);
                }
                mHandler.sendMessageDelayed(mHandler.obtainMessage(H.MSG_CLEAR_CLIENT, pkgUser),
                        PERMISSION_CACHE_PERIOD);
                return client;
            } catch (FileNotFoundException e) {
                // No client exists yet.
            } catch (IOException e) {
                Log.e(TAG, "Can't read client", e);
            } catch (XmlPullParserException e) {
                Log.e(TAG, "Can't read client", e);
            }
            // Can't read or no permissions exist, create a clean object.
            client = new SliceClientPermissions(pkgUser, this);
            synchronized (mCachedClients) {
                mCachedClients.put(pkgUser, client);
            }
        }
        return client;
    }

    private SliceProviderPermissions getProvider(PkgUser pkgUser) {
        SliceProviderPermissions provider;
        synchronized (mCachedProviders) {
            provider = mCachedProviders.get(pkgUser);
        }
        if (provider == null) {
            try (ParserHolder parser = getParser(SliceProviderPermissions.getFileName(pkgUser))) {
                provider = SliceProviderPermissions.createFrom(parser.parser, this);
                synchronized (mCachedProviders) {
                    mCachedProviders.put(pkgUser, provider);
                }
                mHandler.sendMessageDelayed(mHandler.obtainMessage(H.MSG_CLEAR_PROVIDER, pkgUser),
                        PERMISSION_CACHE_PERIOD);
                return provider;
            } catch (FileNotFoundException e) {
                // No provider exists yet.
            } catch (IOException e) {
                Log.e(TAG, "Can't read provider", e);
            } catch (XmlPullParserException e) {
                Log.e(TAG, "Can't read provider", e);
            }
            // Can't read or no permissions exist, create a clean object.
            provider = new SliceProviderPermissions(pkgUser, this);
            synchronized (mCachedProviders) {
                mCachedProviders.put(pkgUser, provider);
            }
        }
        return provider;
    }

    private ParserHolder getParser(String fileName)
            throws FileNotFoundException, XmlPullParserException {
        AtomicFile file = getFile(fileName);
        ParserHolder holder = new ParserHolder();
        holder.input = file.openRead();
        holder.parser = XmlPullParserFactory.newInstance().newPullParser();
        holder.parser.setInput(holder.input, Encoding.UTF_8.name());
        return holder;
    }

    private AtomicFile getFile(String fileName) {
        if (!mSliceDir.exists()) {
            mSliceDir.mkdir();
        }
        return new AtomicFile(new File(mSliceDir, fileName));
    }

    @VisibleForTesting
    void handlePersist() {
        synchronized (this) {
            for (Persistable persistable : mDirty) {
                AtomicFile file = getFile(persistable.getFileName());
                final FileOutputStream stream;
                try {
                    stream = file.startWrite();
                } catch (IOException e) {
                    Slog.w(TAG, "Failed to save access file", e);
                    return;
                }

                try {
                    XmlSerializer out = XmlPullParserFactory.newInstance().newSerializer();
                    out.setOutput(stream, Encoding.UTF_8.name());

                    persistable.writeTo(out);

                    out.flush();
                    file.finishWrite(stream);
                } catch (IOException | XmlPullParserException | RuntimeException e) {
                    Slog.w(TAG, "Failed to save access file, restoring backup", e);
                    file.failWrite(stream);
                }
            }
            mDirty.clear();
        }
    }

    // use addPersistableDirty(); this is just for tests
    @VisibleForTesting
    void addDirtyImmediate(Persistable obj) {
        mDirty.add(obj);
    }

    private void handleRemove(PkgUser pkgUser) {
        getFile(SliceClientPermissions.getFileName(pkgUser)).delete();
        getFile(SliceProviderPermissions.getFileName(pkgUser)).delete();
        mDirty.remove(mCachedClients.remove(pkgUser));
        mDirty.remove(mCachedProviders.remove(pkgUser));
    }

    private final class H extends Handler {
        private static final int MSG_ADD_DIRTY = 1;
        private static final int MSG_PERSIST = 2;
        private static final int MSG_REMOVE = 3;
        private static final int MSG_CLEAR_CLIENT = 4;
        private static final int MSG_CLEAR_PROVIDER = 5;

        public H(Looper looper) {
            super(looper);
        }

        @Override
        public void handleMessage(Message msg) {
            switch (msg.what) {
                case MSG_ADD_DIRTY:
                    mDirty.add((Persistable) msg.obj);
                    break;
                case MSG_PERSIST:
                    handlePersist();
                    break;
                case MSG_REMOVE:
                    handleRemove((PkgUser) msg.obj);
                    break;
                case MSG_CLEAR_CLIENT:
                    synchronized (mCachedClients) {
                        mCachedClients.remove(msg.obj);
                    }
                    break;
                case MSG_CLEAR_PROVIDER:
                    synchronized (mCachedProviders) {
                        mCachedProviders.remove(msg.obj);
                    }
                    break;
            }
        }
    }

    public static class PkgUser {
        private static final String SEPARATOR = "@";
        private static final String FORMAT = "%s" + SEPARATOR + "%d";
        private final String mPkg;
        private final int mUserId;

        public PkgUser(String pkg, int userId) {
            mPkg = pkg;
            mUserId = userId;
        }

        public PkgUser(String pkgUserStr) throws IllegalArgumentException {
            try {
                String[] vals = pkgUserStr.split(SEPARATOR, 2);
                mPkg = vals[0];
                mUserId = Integer.parseInt(vals[1]);
            } catch (Exception e) {
                throw new IllegalArgumentException(e);
            }
        }

        public String getPkg() {
            return mPkg;
        }

        public int getUserId() {
            return mUserId;
        }

        @Override
        public int hashCode() {
            return mPkg.hashCode() + mUserId;
        }

        @Override
        public boolean equals(Object obj) {
            if (!getClass().equals(obj != null ? obj.getClass() : null)) return false;
            PkgUser other = (PkgUser) obj;
            return Objects.equals(other.mPkg, mPkg) && other.mUserId == mUserId;
        }

        @Override
        public String toString() {
            return String.format(FORMAT, mPkg, mUserId);
        }
    }

    private class ParserHolder implements AutoCloseable {

        private InputStream input;
        private XmlPullParser parser;

        @Override
        public void close() throws IOException {
            input.close();
        }
    }
}
