/*
 * Copyright (C) 2020 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.networkstack;

import static android.app.NotificationManager.IMPORTANCE_NONE;

import android.app.Notification;
import android.app.NotificationChannel;
import android.app.NotificationManager;
import android.app.PendingIntent;
import android.content.Context;
import android.content.Intent;
import android.content.pm.PackageManager;
import android.content.res.Resources;
import android.net.ConnectivityManager;
import android.net.LinkProperties;
import android.net.Network;
import android.net.NetworkCapabilities;
import android.net.NetworkRequest;
import android.os.Handler;
import android.os.Looper;
import android.os.UserHandle;
import android.provider.Settings;
import android.text.TextUtils;

import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.StringRes;
import androidx.annotation.VisibleForTesting;

import com.android.networkstack.apishim.NetworkInformationShimImpl;
import com.android.networkstack.apishim.common.CaptivePortalDataShim;
import com.android.networkstack.apishim.common.NetworkInformationShim;

import java.util.Hashtable;
import java.util.function.Consumer;

/**
 * Displays notification related to connected networks.
 */
public class NetworkStackNotifier {
    private final Context mContext;
    private final Handler mHandler;
    private final NotificationManager mNotificationManager;
    private final Dependencies mDependencies;

    @NonNull
    private final Hashtable<Network, TrackedNetworkStatus> mNetworkStatus = new Hashtable<>();
    @Nullable
    private Network mDefaultNetwork;
    @NonNull
    private final NetworkInformationShim mInfoShim = NetworkInformationShimImpl.newInstance();

    /**
     * The TrackedNetworkStatus object is a data class that keeps track of the relevant state of the
     * various networks on the device. For efficiency the members are mutable, which means any
     * instance of this object should only ever be accessed on the looper thread passed in the
     * constructor. Any access (read or write) from any other thread would be incorrect.
     */
    private static class TrackedNetworkStatus {
        private boolean mValidatedNotificationPending;
        private int mShownNotification = NOTE_NONE;
        private LinkProperties mLinkProperties;
        private NetworkCapabilities mNetworkCapabilities;

        private boolean isValidated() {
            if (mNetworkCapabilities == null) return false;
            return mNetworkCapabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED);
        }
    }

    @VisibleForTesting
    protected static final String CHANNEL_CONNECTED = "connected_note_loud";
    @VisibleForTesting
    protected static final String CHANNEL_VENUE_INFO = "connected_note";

    private static final int NOTE_NONE = 0;
    private static final int NOTE_CONNECTED = 1;
    private static final int NOTE_VENUE_INFO = 2;

    private static final int NOTE_ID_NETWORK_INFO = 1;

    @VisibleForTesting
    protected static final long CONNECTED_NOTIFICATION_TIMEOUT_MS = 20_000L;

    protected static class Dependencies {
        public PendingIntent getActivityPendingIntent(Context context, Intent intent, int flags) {
            return PendingIntent.getActivity(context, 0 /* requestCode */, intent, flags);
        }
    }

    public NetworkStackNotifier(@NonNull Context context, @NonNull Looper looper) {
        this(context, looper, new Dependencies());
    }

    protected NetworkStackNotifier(@NonNull Context context, @NonNull Looper looper,
            @NonNull Dependencies dependencies) {
        mContext = context;
        mHandler = new Handler(looper);
        mDependencies = dependencies;
        mNotificationManager = getContextAsUser(mContext, UserHandle.ALL)
                .getSystemService(NotificationManager.class);
        final ConnectivityManager cm = context.getSystemService(ConnectivityManager.class);
        cm.registerDefaultNetworkCallback(new DefaultNetworkCallback(), mHandler);
        cm.registerNetworkCallback(
                new NetworkRequest.Builder()
                        .addTransportType(NetworkCapabilities.TRANSPORT_WIFI).build(),
                new AllNetworksCallback(),
                mHandler);

        createNotificationChannel(CHANNEL_CONNECTED,
                R.string.notification_channel_name_connected,
                R.string.notification_channel_description_connected,
                NotificationManager.IMPORTANCE_HIGH);
        createNotificationChannel(CHANNEL_VENUE_INFO,
                R.string.notification_channel_name_network_venue_info,
                R.string.notification_channel_description_network_venue_info,
                NotificationManager.IMPORTANCE_DEFAULT);
    }

    @VisibleForTesting
    protected Handler getHandler() {
        return mHandler;
    }

    private void createNotificationChannel(@NonNull String id, @StringRes int title,
            @StringRes int description, int importance) {
        final Resources resources = mContext.getResources();
        NotificationChannel channel = new NotificationChannel(id,
                resources.getString(title),
                importance);
        channel.setDescription(resources.getString(description));
        getNotificationManagerForChannels().createNotificationChannel(channel);
    }

    /**
     * Get the NotificationManager to use to query channels, as opposed to posting notifications.
     *
     * Although notifications are posted as USER_ALL, notification channels are always created
     * based on the UID calling NotificationManager, regardless of the context UserHandle.
     * When querying notification channels, using a USER_ALL context would return no channel: the
     * default context (as UserHandle 0 for NetworkStack) must be used.
     */
    private NotificationManager getNotificationManagerForChannels() {
        return mContext.getSystemService(NotificationManager.class);
    }

    /**
     * Notify the NetworkStackNotifier that the captive portal app was opened to show a login UI to
     * the user, but the network has not validated yet. The notifier uses this information to show
     * proper notifications once the network validates.
     */
    public void notifyCaptivePortalValidationPending(@NonNull Network network) {
        mHandler.post(() -> setCaptivePortalValidationPending(network));
    }

    private void setCaptivePortalValidationPending(@NonNull Network network) {
        updateNetworkStatus(network, status -> {
            status.mValidatedNotificationPending = true;
            status.mShownNotification = NOTE_NONE;
        });
    }

    @Nullable
    private CaptivePortalDataShim getCaptivePortalData(@NonNull TrackedNetworkStatus status) {
        return mInfoShim.getCaptivePortalData(status.mLinkProperties);
    }

    private String getSsid(@NonNull TrackedNetworkStatus status) {
        return mInfoShim.getSsid(status.mNetworkCapabilities);
    }

    private void updateNetworkStatus(@NonNull Network network,
            @NonNull Consumer<TrackedNetworkStatus> mutator) {
        final TrackedNetworkStatus status =
                mNetworkStatus.computeIfAbsent(network, n -> new TrackedNetworkStatus());
        mutator.accept(status);
    }

    private void updateNotifications(@NonNull Network network) {
        final TrackedNetworkStatus networkStatus = mNetworkStatus.get(network);
        // The required network attributes callbacks were not fired yet for this network
        if (networkStatus == null) return;
        // Don't show the notification when SSID is unknown to prevent sending something vague to
        // the user.
        final boolean hasSsid = !TextUtils.isEmpty(getSsid(networkStatus));
        final CaptivePortalDataShim capportData = getCaptivePortalData(networkStatus);
        final boolean showVenueInfo = capportData != null && capportData.getVenueInfoUrl() != null
                // Only show venue info on validated networks, to prevent misuse of the notification
                // as an alternate login flow that uses the default browser (which would be broken
                // if the device has mobile data).
                && networkStatus.isValidated()
                && isVenueInfoNotificationEnabled()
                // Most browsers do not yet support opening a page on a non-default network, so the
                // venue info link should not be shown if the network is not the default one.
                && network.equals(mDefaultNetwork)
                && hasSsid;
        final boolean showValidated =
                networkStatus.mValidatedNotificationPending && networkStatus.isValidated()
                && hasSsid;
        final String notificationTag = getNotificationTag(network);

        final Resources res = mContext.getResources();
        final Notification.Builder builder;
        if (showVenueInfo) {
            // Do not re-show the venue info notification even if the previous one had a different
            // URL, to avoid potential abuse where APs could spam the notification with different
            // URLs.
            if (networkStatus.mShownNotification == NOTE_VENUE_INFO) return;

            final Intent infoIntent = new Intent(Intent.ACTION_VIEW)
                    .setFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
                    .setData(capportData.getVenueInfoUrl())
                    .putExtra(ConnectivityManager.EXTRA_NETWORK, network)
                    // Use the network handle as identifier, as there should be only one ACTION_VIEW
                    // pending intent per network.
                    .setIdentifier(Long.toString(network.getNetworkHandle()));

            // If the validated notification should be shown, use the high priority "connected"
            // channel even if the notification contains venue info: the "venue info" notification
            // then doubles as a "connected" notification.
            final String channel = showValidated ? CHANNEL_CONNECTED : CHANNEL_VENUE_INFO;

            // If the venue friendly name is available (in Passpoint use-case), display it.
            // Otherwise, display the SSID.
            final CharSequence friendlyName = capportData.getVenueFriendlyName();
            final CharSequence venueDisplayName = TextUtils.isEmpty(friendlyName)
                    ? getSsid(networkStatus) : friendlyName;

            builder = getNotificationBuilder(channel, networkStatus, res, venueDisplayName)
                    .setContentText(res.getString(R.string.tap_for_info))
                    .setContentIntent(mDependencies.getActivityPendingIntent(
                            getContextAsUser(mContext, UserHandle.CURRENT),
                            infoIntent, PendingIntent.FLAG_IMMUTABLE));

            networkStatus.mShownNotification = NOTE_VENUE_INFO;
        } else if (showValidated) {
            if (networkStatus.mShownNotification == NOTE_CONNECTED) return;

            builder = getNotificationBuilder(CHANNEL_CONNECTED, networkStatus, res,
                    getSsid(networkStatus))
                    .setTimeoutAfter(CONNECTED_NOTIFICATION_TIMEOUT_MS)
                    .setContentText(res.getString(R.string.connected))
                    .setContentIntent(mDependencies.getActivityPendingIntent(
                            getContextAsUser(mContext, UserHandle.CURRENT),
                            new Intent(Settings.ACTION_WIFI_SETTINGS),
                            PendingIntent.FLAG_IMMUTABLE));

            networkStatus.mShownNotification = NOTE_CONNECTED;
        } else {
            if (networkStatus.mShownNotification != NOTE_NONE
                    // Don't dismiss the connected notification: it's generated as one-off and will
                    // be dismissed after a timeout or if the network disconnects.
                    && networkStatus.mShownNotification != NOTE_CONNECTED) {
                dismissNotification(notificationTag, networkStatus);
            }
            return;
        }

        if (showValidated) {
            networkStatus.mValidatedNotificationPending = false;
        }
        mNotificationManager.notify(notificationTag, NOTE_ID_NETWORK_INFO, builder.build());
    }

    private void dismissNotification(@NonNull String tag, @NonNull TrackedNetworkStatus status) {
        mNotificationManager.cancel(tag, NOTE_ID_NETWORK_INFO);
        status.mShownNotification = NOTE_NONE;
    }

    private Notification.Builder getNotificationBuilder(@NonNull String channelId,
            @NonNull TrackedNetworkStatus networkStatus, @NonNull Resources res,
            @NonNull CharSequence networkIdentifier) {
        return new Notification.Builder(mContext, channelId)
                .setContentTitle(networkIdentifier)
                .setSmallIcon(R.drawable.icon_wifi);
    }

    /**
     * Replacement for {@link Context#createContextAsUser(UserHandle, int)}, which is not available
     * in API 29.
     */
    private static Context getContextAsUser(Context baseContext, UserHandle user) {
        try {
            return baseContext.createPackageContextAsUser(
                    baseContext.getPackageName(), 0 /* flags */, user);
        } catch (PackageManager.NameNotFoundException e) {
            throw new IllegalStateException("NetworkStack own package not found", e);
        }
    }

    private boolean isVenueInfoNotificationEnabled() {
        final NotificationChannel channel = getNotificationManagerForChannels()
                .getNotificationChannel(CHANNEL_VENUE_INFO);
        if (channel == null) return false;

        return channel.getImportance() != IMPORTANCE_NONE;
    }

    private static String getNotificationTag(@NonNull Network network) {
        return Long.toString(network.getNetworkHandle());
    }

    private class DefaultNetworkCallback extends ConnectivityManager.NetworkCallback {
        @Override
        public void onAvailable(Network network) {
            updateDefaultNetwork(network);
        }

        @Override
        public void onLost(Network network) {
            updateDefaultNetwork(null);
        }

        private void updateDefaultNetwork(@Nullable Network newNetwork) {
            final Network oldDefault = mDefaultNetwork;
            mDefaultNetwork = newNetwork;
            if (oldDefault != null) updateNotifications(oldDefault);
            if (newNetwork != null) updateNotifications(newNetwork);
        }
    }

    private class AllNetworksCallback extends ConnectivityManager.NetworkCallback {
        @Override
        public void onLinkPropertiesChanged(Network network, LinkProperties linkProperties) {
            updateNetworkStatus(network, status -> status.mLinkProperties = linkProperties);
            updateNotifications(network);
        }

        @Override
        public void onCapabilitiesChanged(@NonNull Network network,
                @NonNull NetworkCapabilities networkCapabilities) {
            updateNetworkStatus(network, s -> s.mNetworkCapabilities = networkCapabilities);
            updateNotifications(network);
        }

        @Override
        public void onLost(Network network) {
            final TrackedNetworkStatus status = mNetworkStatus.remove(network);
            if (status == null) return;
            dismissNotification(getNotificationTag(network), status);
        }
    }
}
