Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit f8c53678 authored by Julia Reynolds's avatar Julia Reynolds
Browse files

Initial notification blocker

Does not currently track stats across reboots.

Test: make ExtServicesUnitTests &&
adb install -r $OUT/data/app/ExtServicesUnitTests/ExtServicesUnitTests.apk &&
adb shell am instrument -w android.ext.services.tests.unit/android.support.test.runner.AndroidJUnitRunner
Bug: 63095540

Change-Id: Ie3a299cdfb229dedf85a07de5cc19f7a8ea423e0
parent 503ed940
Loading
Loading
Loading
Loading
+9 −0
Original line number Diff line number Diff line
@@ -42,6 +42,15 @@
            </intent-filter>
        </service>

        <service android:name=".notification.Assistant"
                 android:label="@string/notification_assistant"
                 android:permission="android.permission.BIND_NOTIFICATION_ASSISTANT_SERVICE"
                 android:exported="true">
            <intent-filter>
                <action android:name="android.service.notification.NotificationAssistantService" />
            </intent-filter>
        </service>

        <library android:name="android.ext.services"/>
    </application>

+3 −0
Original line number Diff line number Diff line
@@ -16,4 +16,7 @@

<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
    <string name="app_name">Android Services Library</string>

    <string name="notification_assistant">Notification Assistant</string>
    <string name="prompt_block_reason">Too many dismissals:views</string>
</resources>
+165 −0
Original line number Diff line number Diff line
/**
 * Copyright (C) 2017 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.ext.services.notification;

import static android.app.NotificationManager.IMPORTANCE_MIN;
import static android.service.notification.NotificationListenerService.Ranking
        .USER_SENTIMENT_NEGATIVE;

import android.app.INotificationManager;
import android.content.Context;
import android.ext.services.R;
import android.os.Bundle;
import android.service.notification.Adjustment;
import android.service.notification.NotificationAssistantService;
import android.service.notification.NotificationStats;
import android.service.notification.StatusBarNotification;
import android.util.ArrayMap;
import android.util.Log;
import android.util.Slog;

import java.util.ArrayList;

/**
 * Notification assistant that provides guidance on notification channel blocking
 */
public class Assistant extends NotificationAssistantService {
    private static final String TAG = "ExtAssistant";
    private static final boolean DEBUG = Log.isLoggable(TAG, Log.DEBUG);

    private static final ArrayList<Integer> DISMISS_WITH_PREJUDICE = new ArrayList<>();
    static {
        DISMISS_WITH_PREJUDICE.add(REASON_CANCEL);
        DISMISS_WITH_PREJUDICE.add(REASON_LISTENER_CANCEL);
    }

    // key : impressions tracker
    // TODO: persist across reboots
    ArrayMap<String, ChannelImpressions> mkeyToImpressions = new ArrayMap<>();
    // SBN key : channel id
    ArrayMap<String, String> mLiveNotifications = new ArrayMap<>();

    private Ranking mFakeRanking = null;

    @Override
    public Adjustment onNotificationEnqueued(StatusBarNotification sbn) {
        if (DEBUG) Log.i(TAG, "ENQUEUED " + sbn.getKey());
        return null;
    }

    @Override
    public void onNotificationPosted(StatusBarNotification sbn, RankingMap rankingMap) {
        if (DEBUG) Log.i(TAG, "POSTED " + sbn.getKey());
        try {
            Ranking ranking = getRanking(sbn.getKey(), rankingMap);
            if (ranking != null && ranking.getChannel() != null) {
                String key = getKey(
                        sbn.getPackageName(), sbn.getUserId(), ranking.getChannel().getId());
                ChannelImpressions ci = mkeyToImpressions.getOrDefault(key,
                        new ChannelImpressions());
                if (ranking.getImportance() > IMPORTANCE_MIN && ci.shouldTriggerBlock()) {
                    adjustNotification(createNegativeAdjustment(
                            sbn.getPackageName(), sbn.getKey(), sbn.getUserId()));
                }
                mkeyToImpressions.put(key, ci);
                mLiveNotifications.put(sbn.getKey(), ranking.getChannel().getId());
            }
        } catch (Throwable e) {
            Log.e(TAG, "Error occurred processing post", e);
        }
    }

    @Override
    public void onNotificationRemoved(StatusBarNotification sbn, RankingMap rankingMap,
            NotificationStats stats, int reason) {
        try {
            String channelId = mLiveNotifications.remove(sbn.getKey());
            String key = getKey(sbn.getPackageName(), sbn.getUserId(), channelId);
            ChannelImpressions ci = mkeyToImpressions.getOrDefault(key, new ChannelImpressions());
            if (stats.hasSeen()) {
                ci.incrementViews();
            }
            if (DISMISS_WITH_PREJUDICE.contains(reason)
                    && !sbn.isAppGroup()
                    && !sbn.getNotification().isGroupChild()
                    && !stats.hasInteracted()
                    && stats.getDismissalSurface() != NotificationStats.DISMISSAL_AOD
                    && stats.getDismissalSurface() != NotificationStats.DISMISSAL_PEEK
                    && stats.getDismissalSurface() != NotificationStats.DISMISSAL_OTHER) {
               if (DEBUG) Log.i(TAG, "increment dismissals");
                ci.incrementDismissals();
            } else {
                if (DEBUG) Slog.i(TAG, "reset streak");
                ci.resetStreak();
            }
            mkeyToImpressions.put(key, ci);
        } catch (Throwable e) {
            Slog.e(TAG, "Error occurred processing removal", e);
        }
    }

    @Override
    public void onNotificationSnoozedUntilContext(StatusBarNotification sbn,
            String snoozeCriterionId) {
    }

    @Override
    public void onListenerConnected() {
        if (DEBUG) Log.i(TAG, "CONNECTED");
        try {
            for (StatusBarNotification sbn : getActiveNotifications()) {
                onNotificationPosted(sbn);
            }
        } catch (Throwable e) {
            Log.e(TAG, "Error occurred on connection", e);
        }
    }

    private String getKey(String pkg, int userId, String channelId) {
        return pkg + "|" + userId + "|" + channelId;
    }

    private Ranking getRanking(String key, RankingMap rankingMap) {
        if (mFakeRanking != null) {
            return mFakeRanking;
        }
        Ranking ranking = new Ranking();
        rankingMap.getRanking(key, ranking);
        return ranking;
    }

    private Adjustment createNegativeAdjustment(String packageName, String key, int user) {
        if (DEBUG) Log.d(TAG, "User probably doesn't want " + key);
        Bundle signals = new Bundle();
        signals.putInt(Adjustment.KEY_USER_SENTIMENT, USER_SENTIMENT_NEGATIVE);
        return new Adjustment(packageName, key,  signals,
                getContext().getString(R.string.prompt_block_reason), user);
    }

    // for testing
    protected void setFakeRanking(Ranking ranking) {
        mFakeRanking = ranking;
    }

    protected void setNoMan(INotificationManager noMan) {
        mNoMan = noMan;
    }

    protected void setContext(Context context) {
        mSystemContext = context;
    }
}
 No newline at end of file
+137 −0
Original line number Diff line number Diff line
/**
 * Copyright (C) 2017 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.ext.services.notification;

import android.os.Parcel;
import android.os.Parcelable;
import android.util.Log;

public final class ChannelImpressions implements Parcelable {
    private static final String TAG = "ExtAssistant.CI";
    private static final boolean DEBUG = Log.isLoggable(TAG, Log.DEBUG);

    static final double DISMISS_TO_VIEW_RATIO_LIMIT = .8;
    static final int STREAK_LIMIT = 2;

    private int mDismissals = 0;
    private int mViews = 0;
    private int mStreak = 0;

    public ChannelImpressions() {
    }

    public ChannelImpressions(int dismissals, int views) {
        mDismissals = dismissals;
        mViews = views;
    }

    protected ChannelImpressions(Parcel in) {
        mDismissals = in.readInt();
        mViews = in.readInt();
        mStreak = in.readInt();
    }

    public int getStreak() {
        return mStreak;
    }

    public int getDismissals() {
        return mDismissals;
    }

    public int getViews() {
        return mViews;
    }

    public void incrementDismissals() {
        mDismissals++;
        mStreak++;
    }

    public void incrementViews() {
        mViews++;
    }

    public void resetStreak() {
        mStreak = 0;
    }

    public boolean shouldTriggerBlock() {
        if (getViews() == 0) {
            return false;
        }
        if (DEBUG) {
            Log.d(TAG, "should trigger? " + getDismissals() + " " + getViews() + " " + getStreak());
        }
        return ((double) getDismissals() / getViews()) > DISMISS_TO_VIEW_RATIO_LIMIT
                && getStreak() > STREAK_LIMIT;
    }

    @Override
    public void writeToParcel(Parcel dest, int flags) {
        dest.writeInt(mDismissals);
        dest.writeInt(mViews);
        dest.writeInt(mStreak);
    }

    @Override
    public int describeContents() {
        return 0;
    }

    public static final Creator<ChannelImpressions> CREATOR = new Creator<ChannelImpressions>() {
        @Override
        public ChannelImpressions createFromParcel(Parcel in) {
            return new ChannelImpressions(in);
        }

        @Override
        public ChannelImpressions[] newArray(int size) {
            return new ChannelImpressions[size];
        }
    };

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;

        ChannelImpressions that = (ChannelImpressions) o;

        if (mDismissals != that.mDismissals) return false;
        if (mViews != that.mViews) return false;
        return mStreak == that.mStreak;
    }

    @Override
    public int hashCode() {
        int result = mDismissals;
        result = 31 * result + mViews;
        result = 31 * result + mStreak;
        return result;
    }

    @Override
    public String toString() {
        final StringBuilder sb = new StringBuilder("ChannelImpressions{");
        sb.append("mDismissals=").append(mDismissals);
        sb.append(", mViews=").append(mViews);
        sb.append(", mStreak=").append(mStreak);
        sb.append('}');
        return sb.toString();
    }
}
+2 −1
Original line number Diff line number Diff line
@@ -12,7 +12,8 @@ LOCAL_STATIC_JAVA_LIBRARIES := \
    mockito-target-minus-junit4 \
    espresso-core \
    truth-prebuilt \
    legacy-android-test
    legacy-android-test \
    testables

# Include all test java files.
LOCAL_SRC_FILES := $(call all-java-files-under, src)
Loading