/*
 * Copyright (C) 2009 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 android.content;

import static com.android.internal.util.function.pooled.PooledLambda.obtainMessage;

import android.accounts.Account;
import android.annotation.MainThread;
import android.annotation.NonNull;
import android.os.Build;
import android.os.Bundle;
import android.os.Handler;
import android.os.IBinder;
import android.os.Process;
import android.os.RemoteException;
import android.os.Trace;
import android.util.Log;

import java.util.HashMap;
import java.util.concurrent.atomic.AtomicInteger;

/**
 * An abstract implementation of a SyncAdapter that spawns a thread to invoke a sync operation.
 * If a sync operation is already in progress when a sync request is received, an error will be
 * returned to the new request and the existing request will be allowed to continue.
 * However if there is no sync in progress then a thread will be spawned and {@link #onPerformSync}
 * will be invoked on that thread.
 * <p>
 * Syncs can be cancelled at any time by the framework. For example a sync that was not
 * user-initiated and lasts longer than 30 minutes will be considered timed-out and cancelled.
 * Similarly the framework will attempt to determine whether or not an adapter is making progress
 * by monitoring its network activity over the course of a minute. If the network traffic over this
 * window is close enough to zero the sync will be cancelled. You can also request the sync be
 * cancelled via {@link ContentResolver#cancelSync(Account, String)} or
 * {@link ContentResolver#cancelSync(SyncRequest)}.
 * <p>
 * A sync is cancelled by issuing a {@link Thread#interrupt()} on the syncing thread. <strong>Either
 * your code in {@link #onPerformSync(Account, Bundle, String, ContentProviderClient, SyncResult)}
 * must check {@link Thread#interrupted()}, or you you must override one of
 * {@link #onSyncCanceled(Thread)}/{@link #onSyncCanceled()}</strong> (depending on whether or not
 * your adapter supports syncing of multiple accounts in parallel). If your adapter does not
 * respect the cancel issued by the framework you run the risk of your app's entire process being
 * killed.
 * <p>
 * In order to be a sync adapter one must extend this class, provide implementations for the
 * abstract methods and write a service that returns the result of {@link #getSyncAdapterBinder()}
 * in the service's {@link android.app.Service#onBind(android.content.Intent)} when invoked
 * with an intent with action <code>android.content.SyncAdapter</code>. This service
 * must specify the following intent filter and metadata tags in its AndroidManifest.xml file
 * <pre>
 *   &lt;intent-filter&gt;
 *     &lt;action android:name="android.content.SyncAdapter" /&gt;
 *   &lt;/intent-filter&gt;
 *   &lt;meta-data android:name="android.content.SyncAdapter"
 *             android:resource="@xml/syncadapter" /&gt;
 * </pre>
 * The <code>android:resource</code> attribute must point to a resource that looks like:
 * <pre>
 * &lt;sync-adapter xmlns:android="http://schemas.android.com/apk/res/android"
 *    android:contentAuthority="authority"
 *    android:accountType="accountType"
 *    android:userVisible="true|false"
 *    android:supportsUploading="true|false"
 *    android:allowParallelSyncs="true|false"
 *    android:isAlwaysSyncable="true|false"
 *    android:syncAdapterSettingsAction="ACTION_OF_SETTINGS_ACTIVITY"
 * /&gt;
 * </pre>
 * <ul>
 * <li>The <code>android:contentAuthority</code> and <code>android:accountType</code> attributes
 * indicate which content authority and for which account types this sync adapter serves.
 * <li><code>android:userVisible</code> defaults to true and controls whether or not this sync
 * adapter shows up in the Sync Settings screen.
 * <li><code>android:supportsUploading</code> defaults
 * to true and if true an upload-only sync will be requested for all syncadapters associated
 * with an authority whenever that authority's content provider does a
 * {@link ContentResolver#notifyChange(android.net.Uri, android.database.ContentObserver, boolean)}
 * with syncToNetwork set to true.
 * <li><code>android:allowParallelSyncs</code> defaults to false and if true indicates that
 * the sync adapter can handle syncs for multiple accounts at the same time. Otherwise
 * the SyncManager will wait until the sync adapter is not in use before requesting that
 * it sync an account's data.
 * <li><code>android:isAlwaysSyncable</code> defaults to false and if true tells the SyncManager
 * to initialize the isSyncable state to 1 for that sync adapter for each account that is added.
 * <li><code>android:syncAdapterSettingsAction</code> defaults to null and if supplied it
 * specifies an Intent action of an activity that can be used to adjust the sync adapter's
 * sync settings. The activity must live in the same package as the sync adapter.
 * </ul>
 */
public abstract class AbstractThreadedSyncAdapter {
    private static final String TAG = "SyncAdapter";

    /**
     * Kernel event log tag.  Also listed in data/etc/event-log-tags.
     * @deprecated Private constant.  May go away in the next release.
     */
    @Deprecated
    public static final int LOG_SYNC_DETAILS = 2743;

    private static final boolean ENABLE_LOG = Build.IS_DEBUGGABLE && Log.isLoggable(TAG, Log.DEBUG);

    private final Context mContext;
    private final AtomicInteger mNumSyncStarts;
    private final ISyncAdapterImpl mISyncAdapterImpl;

    // all accesses to this member variable must be synchronized on mSyncThreadLock
    private final HashMap<Account, SyncThread> mSyncThreads = new HashMap<Account, SyncThread>();
    private final Object mSyncThreadLock = new Object();

    private final boolean mAutoInitialize;
    private boolean mAllowParallelSyncs;

    /**
     * Creates an {@link AbstractThreadedSyncAdapter}.
     * @param context the {@link android.content.Context} that this is running within.
     * @param autoInitialize if true then sync requests that have
     * {@link ContentResolver#SYNC_EXTRAS_INITIALIZE} set will be internally handled by
     * {@link AbstractThreadedSyncAdapter} by calling
     * {@link ContentResolver#setIsSyncable(android.accounts.Account, String, int)} with 1 if it
     * is currently set to <0.
     */
    public AbstractThreadedSyncAdapter(Context context, boolean autoInitialize) {
        this(context, autoInitialize, false /* allowParallelSyncs */);
    }

    /**
     * Creates an {@link AbstractThreadedSyncAdapter}.
     * @param context the {@link android.content.Context} that this is running within.
     * @param autoInitialize if true then sync requests that have
     * {@link ContentResolver#SYNC_EXTRAS_INITIALIZE} set will be internally handled by
     * {@link AbstractThreadedSyncAdapter} by calling
     * {@link ContentResolver#setIsSyncable(android.accounts.Account, String, int)} with 1 if it
     * is currently set to <0.
     * @param allowParallelSyncs if true then allow syncs for different accounts to run
     * at the same time, each in their own thread. This must be consistent with the setting
     * in the SyncAdapter's configuration file.
     */
    public AbstractThreadedSyncAdapter(Context context,
            boolean autoInitialize, boolean allowParallelSyncs) {
        mContext = context;
        mISyncAdapterImpl = new ISyncAdapterImpl();
        mNumSyncStarts = new AtomicInteger(0);
        mAutoInitialize = autoInitialize;
        mAllowParallelSyncs = allowParallelSyncs;
    }

    public Context getContext() {
        return mContext;
    }

    private Account toSyncKey(Account account) {
        if (mAllowParallelSyncs) {
            return account;
        } else {
            return null;
        }
    }

    private class ISyncAdapterImpl extends ISyncAdapter.Stub {
        @Override
        public void onUnsyncableAccount(ISyncAdapterUnsyncableAccountCallback cb) {
            Handler.getMain().sendMessage(obtainMessage(
                    AbstractThreadedSyncAdapter::handleOnUnsyncableAccount,
                    AbstractThreadedSyncAdapter.this, cb));
        }

        @Override
        public void startSync(ISyncContext syncContext, String authority, Account account,
                Bundle extras) {
            if (ENABLE_LOG) {
                if (extras != null) {
                    extras.size(); // Unparcel so its toString() will show the contents.
                }
                Log.d(TAG, "startSync() start " + authority + " " + account + " " + extras);
            }
            try {
                final SyncContext syncContextClient = new SyncContext(syncContext);

                boolean alreadyInProgress;
                // synchronize to make sure that mSyncThreads doesn't change between when we
                // check it and when we use it
                final Account threadsKey = toSyncKey(account);
                synchronized (mSyncThreadLock) {
                    if (!mSyncThreads.containsKey(threadsKey)) {
                        if (mAutoInitialize
                                && extras != null
                                && extras.getBoolean(
                                        ContentResolver.SYNC_EXTRAS_INITIALIZE, false)) {
                            try {
                                if (ContentResolver.getIsSyncable(account, authority) < 0) {
                                    ContentResolver.setIsSyncable(account, authority, 1);
                                }
                            } finally {
                                syncContextClient.onFinished(new SyncResult());
                            }
                            return;
                        }
                        SyncThread syncThread = new SyncThread(
                                "SyncAdapterThread-" + mNumSyncStarts.incrementAndGet(),
                                syncContextClient, authority, account, extras);
                        mSyncThreads.put(threadsKey, syncThread);
                        syncThread.start();
                        alreadyInProgress = false;
                    } else {
                        if (ENABLE_LOG) {
                            Log.d(TAG, "  alreadyInProgress");
                        }
                        alreadyInProgress = true;
                    }
                }

                // do this outside since we don't want to call back into the syncContext while
                // holding the synchronization lock
                if (alreadyInProgress) {
                    syncContextClient.onFinished(SyncResult.ALREADY_IN_PROGRESS);
                }
            } catch (RuntimeException | Error th) {
                if (ENABLE_LOG) {
                    Log.d(TAG, "startSync() caught exception", th);
                }
                throw th;
            } finally {
                if (ENABLE_LOG) {
                    Log.d(TAG, "startSync() finishing");
                }
            }
        }

        @Override
        public void cancelSync(ISyncContext syncContext) {
            try {
                // synchronize to make sure that mSyncThreads doesn't change between when we
                // check it and when we use it
                SyncThread info = null;
                synchronized (mSyncThreadLock) {
                    for (SyncThread current : mSyncThreads.values()) {
                        if (current.mSyncContext.getSyncContextBinder() == syncContext.asBinder()) {
                            info = current;
                            break;
                        }
                    }
                }
                if (info != null) {
                    if (ENABLE_LOG) {
                        Log.d(TAG, "cancelSync() " + info.mAuthority + " " + info.mAccount);
                    }
                    if (mAllowParallelSyncs) {
                        onSyncCanceled(info);
                    } else {
                        onSyncCanceled();
                    }
                } else {
                    if (ENABLE_LOG) {
                        Log.w(TAG, "cancelSync() unknown context");
                    }
                }
            } catch (RuntimeException | Error th) {
                if (ENABLE_LOG) {
                    Log.d(TAG, "cancelSync() caught exception", th);
                }
                throw th;
            } finally {
                if (ENABLE_LOG) {
                    Log.d(TAG, "cancelSync() finishing");
                }
            }
        }
    }

    /**
     * The thread that invokes {@link AbstractThreadedSyncAdapter#onPerformSync}. It also acquires
     * the provider for this sync before calling onPerformSync and releases it afterwards. Cancel
     * this thread in order to cancel the sync.
     */
    private class SyncThread extends Thread {
        private final SyncContext mSyncContext;
        private final String mAuthority;
        private final Account mAccount;
        private final Bundle mExtras;
        private final Account mThreadsKey;

        private SyncThread(String name, SyncContext syncContext, String authority,
                Account account, Bundle extras) {
            super(name);
            mSyncContext = syncContext;
            mAuthority = authority;
            mAccount = account;
            mExtras = extras;
            mThreadsKey = toSyncKey(account);
        }

        @Override
        public void run() {
            Process.setThreadPriority(Process.THREAD_PRIORITY_BACKGROUND);

            if (ENABLE_LOG) {
                Log.d(TAG, "Thread started");
            }

            // Trace this sync instance.  Note, conceptually this should be in
            // SyncStorageEngine.insertStartSyncEvent(), but the trace functions require unique
            // threads in order to track overlapping operations, so we'll do it here for now.
            Trace.traceBegin(Trace.TRACE_TAG_SYNC_MANAGER, mAuthority);

            SyncResult syncResult = new SyncResult();
            ContentProviderClient provider = null;
            try {
                if (isCanceled()) {
                    if (ENABLE_LOG) {
                        Log.d(TAG, "Already canceled");
                    }
                    return;
                }
                if (ENABLE_LOG) {
                    Log.d(TAG, "Calling onPerformSync...");
                }

                provider = mContext.getContentResolver().acquireContentProviderClient(mAuthority);
                if (provider != null) {
                    AbstractThreadedSyncAdapter.this.onPerformSync(mAccount, mExtras,
                            mAuthority, provider, syncResult);
                } else {
                    syncResult.databaseError = true;
                }

                if (ENABLE_LOG) {
                    Log.d(TAG, "onPerformSync done");
                }

            } catch (SecurityException e) {
                if (ENABLE_LOG) {
                    Log.d(TAG, "SecurityException", e);
                }
                AbstractThreadedSyncAdapter.this.onSecurityException(mAccount, mExtras,
                        mAuthority, syncResult);
                syncResult.databaseError = true;
            } catch (RuntimeException | Error th) {
                if (ENABLE_LOG) {
                    Log.d(TAG, "caught exception", th);
                }
                throw th;
            } finally {
                Trace.traceEnd(Trace.TRACE_TAG_SYNC_MANAGER);

                if (provider != null) {
                    provider.release();
                }
                if (!isCanceled()) {
                    mSyncContext.onFinished(syncResult);
                }
                // synchronize so that the assignment will be seen by other threads
                // that also synchronize accesses to mSyncThreads
                synchronized (mSyncThreadLock) {
                    mSyncThreads.remove(mThreadsKey);
                }

                if (ENABLE_LOG) {
                    Log.d(TAG, "Thread finished");
                }
            }
        }

        private boolean isCanceled() {
            return Thread.currentThread().isInterrupted();
        }
    }

    /**
     * @return a reference to the IBinder of the SyncAdapter service.
     */
    public final IBinder getSyncAdapterBinder() {
        return mISyncAdapterImpl.asBinder();
    }

    /**
     * Handle a call of onUnsyncableAccount.
     *
     * @param cb The callback to report the return value to
     */
    private void handleOnUnsyncableAccount(@NonNull ISyncAdapterUnsyncableAccountCallback cb) {
        boolean doSync;
        try {
            doSync = onUnsyncableAccount();
        } catch (RuntimeException e) {
            Log.e(TAG, "Exception while calling onUnsyncableAccount, assuming 'true'", e);
            doSync = true;
        }

        try {
            cb.onUnsyncableAccountDone(doSync);
        } catch (RemoteException e) {
            Log.e(TAG, "Could not report result of onUnsyncableAccount", e);
        }
    }

    /**
     * Allows to defer syncing until all accounts are properly set up.
     *
     * <p>Called when a account / authority pair
     * <ul>
     * <li>that can be handled by this adapter</li>
     * <li>{@link ContentResolver#requestSync(SyncRequest) is synced}</li>
     * <li>and the account/provider {@link ContentResolver#getIsSyncable(Account, String) has
     * unknown state (<0)}.</li>
     * </ul>
     *
     * <p>This might be called on a different service connection as {@link #onPerformSync}.
     *
     * <p>The system expects this method to immediately return. If the call stalls the system
     * behaves as if this method returned {@code true}. If it is required to perform a longer task
     * (such as interacting with the user), return {@code false} and proceed in a difference
     * context, such as an {@link android.app.Activity}, or foreground service. The sync can then be
     * rescheduled once the account becomes syncable.
     *
     * @return If {@code false} syncing is deferred. Returns {@code true} by default, i.e. by
     *         default syncing starts immediately.
     */
    @MainThread
    public boolean onUnsyncableAccount() {
        return true;
    }

    /**
     * Perform a sync for this account. SyncAdapter-specific parameters may
     * be specified in extras, which is guaranteed to not be null. Invocations
     * of this method are guaranteed to be serialized.
     *
     * @param account the account that should be synced
     * @param extras SyncAdapter-specific parameters
     * @param authority the authority of this sync request
     * @param provider a ContentProviderClient that points to the ContentProvider for this
     *   authority
     * @param syncResult SyncAdapter-specific parameters
     */
    public abstract void onPerformSync(Account account, Bundle extras,
            String authority, ContentProviderClient provider, SyncResult syncResult);

    /**
     * Report that there was a security exception when opening the content provider
     * prior to calling {@link #onPerformSync}.  This will be treated as a sync
     * database failure.
     *
     * @param account the account that attempted to sync
     * @param extras SyncAdapter-specific parameters
     * @param authority the authority of the failed sync request
     * @param syncResult SyncAdapter-specific parameters
     */
    public void onSecurityException(Account account, Bundle extras,
            String authority, SyncResult syncResult) {
    }

    /**
     * Indicates that a sync operation has been canceled. This will be invoked on a separate
     * thread than the sync thread and so you must consider the multi-threaded implications
     * of the work that you do in this method.
     * <p>
     * This will only be invoked when the SyncAdapter indicates that it doesn't support
     * parallel syncs.
     */
    public void onSyncCanceled() {
        final SyncThread syncThread;
        synchronized (mSyncThreadLock) {
            syncThread = mSyncThreads.get(null);
        }
        if (syncThread != null) {
            syncThread.interrupt();
        }
    }

    /**
     * Indicates that a sync operation has been canceled. This will be invoked on a separate
     * thread than the sync thread and so you must consider the multi-threaded implications
     * of the work that you do in this method.
     * <p>
     * This will only be invoked when the SyncAdapter indicates that it does support
     * parallel syncs.
     * @param thread the Thread of the sync that is to be canceled.
     */
    public void onSyncCanceled(Thread thread) {
        thread.interrupt();
    }
}
