blob: 9a6ea2c2aeb889b4e8e5e5071dd2c79f441bdd2d [file] [log] [blame]
/*
* 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.server.notification;
import static android.service.notification.NotificationListenerService.REASON_ASSISTANT_CANCEL;
import static android.service.notification.NotificationListenerService.REASON_CANCEL;
import static android.service.notification.NotificationListenerService.REASON_CLEAR_DATA;
import static android.service.notification.NotificationListenerService.REASON_CLICK;
import android.annotation.DurationMillisLong;
import android.annotation.NonNull;
import android.annotation.Nullable;
import android.app.Notification;
import android.app.NotificationChannel;
import android.app.Person;
import android.os.Bundle;
import android.service.notification.NotificationListenerService;
import android.service.notification.NotificationStats;
import android.util.Log;
import com.android.internal.config.sysui.SystemUiSystemPropertiesFlags;
import com.android.internal.config.sysui.SystemUiSystemPropertiesFlags.NotificationFlags;
import com.android.internal.logging.InstanceId;
import com.android.internal.logging.UiEvent;
import com.android.internal.logging.UiEventLogger;
import com.android.internal.util.FrameworkStatsLog;
import java.time.Duration;
import java.util.ArrayList;
import java.util.Objects;
/**
* Interface for writing NotificationReported atoms to statsd log. Use NotificationRecordLoggerImpl
* in production. Use NotificationRecordLoggerFake for testing.
* @hide
*/
interface NotificationRecordLogger {
static final String TAG = "NotificationRecordLogger";
// The high-level interface used by clients.
/**
* Prepare to log an atom reflecting the posting or update of a notification.
*
* The returned {@link NotificationReported} object, if any, should be supplied to
* {@link #logNotificationPosted}. Because only some updates are considered "interesting
* enough" to log, this method may return {@code null}. In that case, the follow-up call
* should not be performed.
*
* @param r The new {@link NotificationRecord}.
* @param old The previous {@link NotificationRecord}. Null if there was no previous record.
* @param position The position at which this notification is ranked.
* @param buzzBeepBlink Logging code reflecting whether this notification alerted the user.
* @param groupId The {@link InstanceId} of the group summary notification, or null.
*/
@Nullable
default NotificationReported prepareToLogNotificationPosted(@Nullable NotificationRecord r,
@Nullable NotificationRecord old,
int position, int buzzBeepBlink,
InstanceId groupId) {
NotificationRecordPair p = new NotificationRecordPair(r, old);
if (!p.shouldLogReported(buzzBeepBlink)) {
return null;
}
return new NotificationReported(p, NotificationReportedEvent.fromRecordPair(p), position,
buzzBeepBlink, groupId);
}
/**
* Log a NotificationReported atom reflecting the posting or update of a notification.
*/
void logNotificationPosted(NotificationReported nr);
/**
* Logs a NotificationReported atom reflecting an adjustment to a notification.
* Unlike for posted notifications, this method is guaranteed to log a notification update,
* so the caller must take responsibility for checking that that logging update is necessary,
* and that the notification is meaningfully changed.
* @param r The NotificationRecord. If null, no action is taken.
* @param position The position at which this notification is ranked.
* @param buzzBeepBlink Logging code reflecting whether this notification alerted the user.
* @param groupId The instance Id of the group summary notification, or null.
*/
void logNotificationAdjusted(@Nullable NotificationRecord r,
int position, int buzzBeepBlink,
InstanceId groupId);
/**
* Logs a notification cancel / dismiss event using UiEventReported (event ids from the
* NotificationCancelledEvents enum).
* @param r The NotificationRecord. If null, no action is taken.
* @param reason The reason the notification was canceled.
* @param dismissalSurface The surface the notification was dismissed from.
*/
default void logNotificationCancelled(@Nullable NotificationRecord r,
@NotificationListenerService.NotificationCancelReason int reason,
@NotificationStats.DismissalSurface int dismissalSurface) {
log(NotificationCancelledEvent.fromCancelReason(reason, dismissalSurface), r);
}
/**
* Logs a notification visibility change event using UiEventReported (event ids from the
* NotificationEvents enum).
* @param r The NotificationRecord. If null, no action is taken.
* @param visible True if the notification became visible.
*/
default void logNotificationVisibility(@Nullable NotificationRecord r, boolean visible) {
log(NotificationEvent.fromVisibility(visible), r);
}
// The UiEventReported logging methods are implemented in terms of this lower-level interface.
/** Logs a UiEventReported event for the given notification. */
void log(UiEventLogger.UiEventEnum event, NotificationRecord r);
/** Logs a UiEventReported event that is not associated with any notification. */
void log(UiEventLogger.UiEventEnum event);
/**
* The UiEvent enums that this class can log.
*/
enum NotificationReportedEvent implements UiEventLogger.UiEventEnum {
@UiEvent(doc = "New notification enqueued to post")
NOTIFICATION_POSTED(162),
@UiEvent(doc = "Notification substantially updated, or alerted again.")
NOTIFICATION_UPDATED(163),
@UiEvent(doc = "Notification adjusted by assistant.")
NOTIFICATION_ADJUSTED(908);
private final int mId;
NotificationReportedEvent(int id) {
mId = id;
}
@Override public int getId() {
return mId;
}
public static NotificationReportedEvent fromRecordPair(NotificationRecordPair p) {
return (p.old != null) ? NotificationReportedEvent.NOTIFICATION_UPDATED :
NotificationReportedEvent.NOTIFICATION_POSTED;
}
}
enum NotificationCancelledEvent implements UiEventLogger.UiEventEnum {
INVALID(0),
@UiEvent(doc = "Notification was canceled due to a notification click.")
NOTIFICATION_CANCEL_CLICK(164),
@UiEvent(doc = "Notification was canceled due to a user dismissal, surface not specified.")
NOTIFICATION_CANCEL_USER_OTHER(165),
@UiEvent(doc = "Notification was canceled due to a user dismiss-all (from the notification"
+ " shade).")
NOTIFICATION_CANCEL_USER_CANCEL_ALL(166),
@UiEvent(doc = "Notification was canceled due to an inflation error.")
NOTIFICATION_CANCEL_ERROR(167),
@UiEvent(doc = "Notification was canceled by the package manager modifying the package.")
NOTIFICATION_CANCEL_PACKAGE_CHANGED(168),
@UiEvent(doc = "Notification was canceled by the owning user context being stopped.")
NOTIFICATION_CANCEL_USER_STOPPED(169),
@UiEvent(doc = "Notification was canceled by the user banning the package.")
NOTIFICATION_CANCEL_PACKAGE_BANNED(170),
@UiEvent(doc = "Notification was canceled by the app canceling this specific notification.")
NOTIFICATION_CANCEL_APP_CANCEL(171),
@UiEvent(doc = "Notification was canceled by the app cancelling all its notifications.")
NOTIFICATION_CANCEL_APP_CANCEL_ALL(172),
@UiEvent(doc = "Notification was canceled by a listener reporting a user dismissal.")
NOTIFICATION_CANCEL_LISTENER_CANCEL(173),
@UiEvent(doc = "Notification was canceled by a listener reporting a user dismiss all.")
NOTIFICATION_CANCEL_LISTENER_CANCEL_ALL(174),
@UiEvent(doc = "Notification was canceled because it was a member of a canceled group.")
NOTIFICATION_CANCEL_GROUP_SUMMARY_CANCELED(175),
@UiEvent(doc = "Notification was canceled because it was an invisible member of a group.")
NOTIFICATION_CANCEL_GROUP_OPTIMIZATION(176),
@UiEvent(doc = "Notification was canceled by the device administrator suspending the "
+ "package.")
NOTIFICATION_CANCEL_PACKAGE_SUSPENDED(177),
@UiEvent(doc = "Notification was canceled by the owning managed profile being turned off.")
NOTIFICATION_CANCEL_PROFILE_TURNED_OFF(178),
@UiEvent(doc = "Autobundled summary notification was canceled because its group was "
+ "unbundled")
NOTIFICATION_CANCEL_UNAUTOBUNDLED(179),
@UiEvent(doc = "Notification was canceled by the user banning the channel.")
NOTIFICATION_CANCEL_CHANNEL_BANNED(180),
@UiEvent(doc = "Notification was snoozed.")
NOTIFICATION_CANCEL_SNOOZED(181),
@UiEvent(doc = "Notification was canceled due to timeout")
NOTIFICATION_CANCEL_TIMEOUT(182),
@UiEvent(doc = "Notification was canceled due to the backing channel being deleted")
NOTIFICATION_CANCEL_CHANNEL_REMOVED(1261),
@UiEvent(doc = "Notification was canceled due to the app's storage being cleared")
NOTIFICATION_CANCEL_CLEAR_DATA(1262),
// Values above this line must remain in the same order as the corresponding
// NotificationCancelReason enum values.
@UiEvent(doc = "Notification was canceled due to user dismissal of a peeking notification.")
NOTIFICATION_CANCEL_USER_PEEK(190),
@UiEvent(doc = "Notification was canceled due to user dismissal from the always-on display")
NOTIFICATION_CANCEL_USER_AOD(191),
@UiEvent(doc = "Notification was canceled due to user dismissal from a bubble")
NOTIFICATION_CANCEL_USER_BUBBLE(1228),
@UiEvent(doc = "Notification was canceled due to user dismissal from the lockscreen")
NOTIFICATION_CANCEL_USER_LOCKSCREEN(193),
@UiEvent(doc = "Notification was canceled due to user dismissal from the notification"
+ " shade.")
NOTIFICATION_CANCEL_USER_SHADE(192),
@UiEvent(doc = "Notification was canceled due to an assistant adjustment update.")
NOTIFICATION_CANCEL_ASSISTANT(906);
private final int mId;
NotificationCancelledEvent(int id) {
mId = id;
}
@Override public int getId() {
return mId;
}
public static NotificationCancelledEvent fromCancelReason(
@NotificationListenerService.NotificationCancelReason int reason,
@NotificationStats.DismissalSurface int surface) {
// Shouldn't be possible to get a non-dismissed notification here.
if (surface == NotificationStats.DISMISSAL_NOT_DISMISSED) {
Log.wtf(TAG, "Unexpected surface: " + surface + " with reason " + reason);
return INVALID;
}
// User cancels have a meaningful surface, which we differentiate by. See b/149038335
// for caveats.
if (reason == REASON_CANCEL) {
switch (surface) {
case NotificationStats.DISMISSAL_PEEK:
return NOTIFICATION_CANCEL_USER_PEEK;
case NotificationStats.DISMISSAL_AOD:
return NOTIFICATION_CANCEL_USER_AOD;
case NotificationStats.DISMISSAL_SHADE:
return NOTIFICATION_CANCEL_USER_SHADE;
case NotificationStats.DISMISSAL_BUBBLE:
return NOTIFICATION_CANCEL_USER_BUBBLE;
case NotificationStats.DISMISSAL_LOCKSCREEN:
return NOTIFICATION_CANCEL_USER_LOCKSCREEN;
case NotificationStats.DISMISSAL_OTHER:
return NOTIFICATION_CANCEL_USER_OTHER;
default:
Log.wtf(TAG, "Unexpected surface: " + surface + " with reason " + reason);
return INVALID;
}
} else {
if ((REASON_CLICK <= reason) && (reason <= REASON_CLEAR_DATA)) {
return NotificationCancelledEvent.values()[reason];
}
if (reason == REASON_ASSISTANT_CANCEL) {
return NotificationCancelledEvent.NOTIFICATION_CANCEL_ASSISTANT;
}
Log.wtf(TAG, "Unexpected reason: " + reason + " with surface " + surface);
return INVALID;
}
}
}
enum NotificationEvent implements UiEventLogger.UiEventEnum {
@UiEvent(doc = "Notification became visible.")
NOTIFICATION_OPEN(197),
@UiEvent(doc = "Notification stopped being visible.")
NOTIFICATION_CLOSE(198),
@UiEvent(doc = "Notification was snoozed.")
NOTIFICATION_SNOOZED(317),
@UiEvent(doc = "Notification was not posted because its app is snoozed.")
NOTIFICATION_NOT_POSTED_SNOOZED(319),
@UiEvent(doc = "Notification was clicked.")
NOTIFICATION_CLICKED(320),
@UiEvent(doc = "Notification action was clicked; unexpected position.")
NOTIFICATION_ACTION_CLICKED(321),
@UiEvent(doc = "Notification detail was expanded due to non-user action.")
NOTIFICATION_DETAIL_OPEN_SYSTEM(327),
@UiEvent(doc = "Notification detail was collapsed due to non-user action.")
NOTIFICATION_DETAIL_CLOSE_SYSTEM(328),
@UiEvent(doc = "Notification detail was expanded due to user action.")
NOTIFICATION_DETAIL_OPEN_USER(329),
@UiEvent(doc = "Notification detail was collapsed due to user action.")
NOTIFICATION_DETAIL_CLOSE_USER(330),
@UiEvent(doc = "Notification direct reply action was used.")
NOTIFICATION_DIRECT_REPLIED(331),
@UiEvent(doc = "Notification smart reply action was used.")
NOTIFICATION_SMART_REPLIED(332),
@UiEvent(doc = "Notification smart reply action was visible.")
NOTIFICATION_SMART_REPLY_VISIBLE(333),
@UiEvent(doc = "App-generated notification action at position 0 was clicked.")
NOTIFICATION_ACTION_CLICKED_0(450),
@UiEvent(doc = "App-generated notification action at position 1 was clicked.")
NOTIFICATION_ACTION_CLICKED_1(451),
@UiEvent(doc = "App-generated notification action at position 2 was clicked.")
NOTIFICATION_ACTION_CLICKED_2(452),
@UiEvent(doc = "Contextual notification action at position 0 was clicked.")
NOTIFICATION_CONTEXTUAL_ACTION_CLICKED_0(453),
@UiEvent(doc = "Contextual notification action at position 1 was clicked.")
NOTIFICATION_CONTEXTUAL_ACTION_CLICKED_1(454),
@UiEvent(doc = "Contextual notification action at position 2 was clicked.")
NOTIFICATION_CONTEXTUAL_ACTION_CLICKED_2(455),
@UiEvent(doc = "Notification assistant generated notification action at 0 was clicked.")
NOTIFICATION_ASSIST_ACTION_CLICKED_0(456),
@UiEvent(doc = "Notification assistant generated notification action at 1 was clicked.")
NOTIFICATION_ASSIST_ACTION_CLICKED_1(457),
@UiEvent(doc = "Notification assistant generated notification action at 2 was clicked.")
NOTIFICATION_ASSIST_ACTION_CLICKED_2(458);
private final int mId;
NotificationEvent(int id) {
mId = id;
}
@Override public int getId() {
return mId;
}
public static NotificationEvent fromVisibility(boolean visible) {
return visible ? NOTIFICATION_OPEN : NOTIFICATION_CLOSE;
}
public static NotificationEvent fromExpanded(boolean expanded, boolean userAction) {
if (userAction) {
return expanded ? NOTIFICATION_DETAIL_OPEN_USER : NOTIFICATION_DETAIL_CLOSE_USER;
}
return expanded ? NOTIFICATION_DETAIL_OPEN_SYSTEM : NOTIFICATION_DETAIL_CLOSE_SYSTEM;
}
public static NotificationEvent fromAction(int index, boolean isAssistant,
boolean isContextual) {
if (index < 0 || index > 2) {
return NOTIFICATION_ACTION_CLICKED;
}
if (isAssistant) { // Assistant actions are contextual by definition
return NotificationEvent.values()[
NOTIFICATION_ASSIST_ACTION_CLICKED_0.ordinal() + index];
}
if (isContextual) {
return NotificationEvent.values()[
NOTIFICATION_CONTEXTUAL_ACTION_CLICKED_0.ordinal() + index];
}
return NotificationEvent.values()[NOTIFICATION_ACTION_CLICKED_0.ordinal() + index];
}
}
enum NotificationPanelEvent implements UiEventLogger.UiEventEnum {
@UiEvent(doc = "Notification panel became visible.")
NOTIFICATION_PANEL_OPEN(325),
@UiEvent(doc = "Notification panel stopped being visible.")
NOTIFICATION_PANEL_CLOSE(326);
private final int mId;
NotificationPanelEvent(int id) {
mId = id;
}
@Override public int getId() {
return mId;
}
}
/**
* A helper for extracting logging information from one or two NotificationRecords.
*/
class NotificationRecordPair {
public final NotificationRecord r, old;
/**
* Construct from one or two NotificationRecords.
* @param r The new NotificationRecord. If null, only shouldLog() method is usable.
* @param old The previous NotificationRecord. Null if there was no previous record.
*/
NotificationRecordPair(@Nullable NotificationRecord r, @Nullable NotificationRecord old) {
this.r = r;
this.old = old;
}
/**
* @return True if old is null, alerted, or important logged fields have changed.
*/
boolean shouldLogReported(int buzzBeepBlink) {
if (r == null) {
return false;
}
if ((old == null) || (buzzBeepBlink > 0)) {
return true;
}
return !(Objects.equals(r.getSbn().getChannelIdLogTag(),
old.getSbn().getChannelIdLogTag())
&& Objects.equals(r.getSbn().getGroupLogTag(), old.getSbn().getGroupLogTag())
&& (r.getSbn().getNotification().isGroupSummary()
== old.getSbn().getNotification().isGroupSummary())
&& Objects.equals(r.getSbn().getNotification().category,
old.getSbn().getNotification().category)
&& (r.getImportance() == old.getImportance())
&& (getLoggingImportance(r) == getLoggingImportance(old))
&& r.rankingScoreMatches(old.getRankingScore()));
}
/**
* @return hash code for the notification style class, or 0 if none exists.
*/
public int getStyle() {
return getStyle(r.getSbn().getNotification().extras);
}
private int getStyle(@Nullable Bundle extras) {
if (extras != null) {
String template = extras.getString(Notification.EXTRA_TEMPLATE);
if (template != null && !template.isEmpty()) {
return template.hashCode();
}
}
return 0;
}
int getNumPeople() {
return getNumPeople(r.getSbn().getNotification().extras);
}
private int getNumPeople(@Nullable Bundle extras) {
if (extras != null) {
ArrayList<Person> people = extras.getParcelableArrayList(
Notification.EXTRA_PEOPLE_LIST, android.app.Person.class);
if (people != null && !people.isEmpty()) {
return people.size();
}
}
return 0;
}
int getAssistantHash() {
String assistant = r.getAdjustmentIssuer();
return (assistant == null) ? 0 : assistant.hashCode();
}
int getInstanceId() {
return (r.getSbn().getInstanceId() == null ? 0 : r.getSbn().getInstanceId().getId());
}
/**
* @return Small hash of the notification ID, and tag (if present).
*/
int getNotificationIdHash() {
return SmallHash.hash(Objects.hashCode(r.getSbn().getTag()) ^ r.getSbn().getId());
}
/**
* @return Small hash of the channel ID, if present, or 0 otherwise.
*/
int getChannelIdHash() {
return SmallHash.hash(r.getSbn().getNotification().getChannelId());
}
/**
* @return Small hash of the group ID, respecting group override if present. 0 otherwise.
*/
int getGroupIdHash() {
return SmallHash.hash(r.getSbn().getGroup());
}
}
/** Data object corresponding to a NotificationReported atom.
*
* Fields must be kept in sync with frameworks/proto_logging/stats/atoms.proto.
*/
class NotificationReported {
final int event_id;
final int uid;
final String package_name;
final int instance_id;
final int notification_id_hash;
final int channel_id_hash;
final int group_id_hash;
final int group_instance_id;
final boolean is_group_summary;
final String category;
final int style;
final int num_people;
final int position;
final int importance;
final int alerting;
final int importance_source;
final int importance_initial;
final int importance_initial_source;
final int importance_asst;
final int assistant_hash;
final float assistant_ranking_score;
final boolean is_ongoing;
final boolean is_foreground_service;
final long timeout_millis;
final boolean is_non_dismissible;
final int fsi_state;
final boolean is_locked;
final int age_in_minutes;
@DurationMillisLong long post_duration_millis; // Not final; calculated at the end.
NotificationReported(NotificationRecordPair p,
NotificationReportedEvent eventType, int position, int buzzBeepBlink,
InstanceId groupId) {
this.event_id = eventType.getId();
this.uid = p.r.getUid();
this.package_name = p.r.getSbn().getPackageName();
this.instance_id = p.getInstanceId();
this.notification_id_hash = p.getNotificationIdHash();
this.channel_id_hash = p.getChannelIdHash();
this.group_id_hash = p.getGroupIdHash();
this.group_instance_id = (groupId == null) ? 0 : groupId.getId();
this.is_group_summary = p.r.getSbn().getNotification().isGroupSummary();
this.category = p.r.getSbn().getNotification().category;
this.style = p.getStyle();
this.num_people = p.getNumPeople();
this.position = position;
this.importance = NotificationRecordLogger.getLoggingImportance(p.r);
this.alerting = buzzBeepBlink;
this.importance_source = p.r.getImportanceExplanationCode();
this.importance_initial = p.r.getInitialImportance();
this.importance_initial_source = p.r.getInitialImportanceExplanationCode();
this.importance_asst = p.r.getAssistantImportance();
this.assistant_hash = p.getAssistantHash();
this.assistant_ranking_score = p.r.getRankingScore();
this.is_ongoing = p.r.getSbn().isOngoing();
this.is_foreground_service = NotificationRecordLogger.isForegroundService(p.r);
this.timeout_millis = p.r.getSbn().getNotification().getTimeoutAfter();
this.is_non_dismissible = NotificationRecordLogger.isNonDismissible(p.r);
final boolean hasFullScreenIntent =
p.r.getSbn().getNotification().fullScreenIntent != null;
final boolean hasFsiRequestedButDeniedFlag = (p.r.getSbn().getNotification().flags
& Notification.FLAG_FSI_REQUESTED_BUT_DENIED) != 0;
this.fsi_state = NotificationRecordLogger.getFsiState(
hasFullScreenIntent, hasFsiRequestedButDeniedFlag, eventType);
this.is_locked = p.r.isLocked();
this.age_in_minutes = NotificationRecordLogger.getAgeInMinutes(
p.r.getSbn().getPostTime(), p.r.getSbn().getNotification().when);
}
}
/**
* @param r NotificationRecord
* @return Logging importance of record, taking important conversation channels into account.
*/
static int getLoggingImportance(@NonNull NotificationRecord r) {
final int importance = r.getImportance();
final NotificationChannel channel = r.getChannel();
if (channel == null) {
return importance;
}
return NotificationChannelLogger.getLoggingImportance(channel, importance);
}
/**
* @param r NotificationRecord
* @return Whether the notification is a foreground service notification.
*/
static boolean isForegroundService(@NonNull NotificationRecord r) {
if (r.getSbn() == null || r.getSbn().getNotification() == null) {
return false;
}
return (r.getSbn().getNotification().flags & Notification.FLAG_FOREGROUND_SERVICE) != 0;
}
/**
* @return Whether the notification is a non-dismissible notification.
*/
static boolean isNonDismissible(@NonNull NotificationRecord r) {
if (r.getSbn() == null || r.getSbn().getNotification() == null) {
return false;
}
return (r.getNotification().flags & Notification.FLAG_NO_DISMISS) != 0;
}
/**
* @return FrameworkStatsLog enum of the state of the full screen intent posted with this
* notification.
*/
static int getFsiState(boolean hasFullScreenIntent,
boolean hasFsiRequestedButDeniedFlag,
NotificationReportedEvent eventType) {
if (eventType == NotificationReportedEvent.NOTIFICATION_UPDATED) {
// Zeroes in protos take zero bandwidth, but non-zero numbers take bandwidth,
// so we should log 0 when possible.
return 0;
}
if (hasFullScreenIntent) {
return FrameworkStatsLog.NOTIFICATION_REPORTED__FSI_STATE__FSI_ALLOWED;
}
if (hasFsiRequestedButDeniedFlag) {
return FrameworkStatsLog.NOTIFICATION_REPORTED__FSI_STATE__FSI_DENIED;
}
return FrameworkStatsLog.NOTIFICATION_REPORTED__FSI_STATE__NO_FSI;
}
/**
* @param postTimeMs time (in {@link System#currentTimeMillis} time) the notification was posted
* @param whenMs A timestamp related to this notification, in milliseconds since the epoch.
* @return difference in duration as an integer in minutes
*/
static int getAgeInMinutes(long postTimeMs, long whenMs) {
return (int) Duration.ofMillis(postTimeMs - whenMs).toMinutes();
}
}