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

Commit 19a7c0ed authored by linyuh's avatar linyuh Committed by Eric Erfanian
Browse files

Better a11y for new call log entries.

Bug: 70989658
Test: CallLogDatesTest, CallLogEntryDescriptionsTest, NewCallLogViewHolderTest
PiperOrigin-RevId: 197811739
Change-Id: I0f9d1e79d8e687efffbb1dac01aaf6fa26a45f6a
parent 2ad3c08b
Loading
Loading
Loading
Loading
+35 −0
Original line number Diff line number Diff line
@@ -26,12 +26,16 @@ import android.telecom.PhoneAccount;
import android.telecom.PhoneAccountHandle;
import android.text.TextUtils;
import android.view.View;
import android.view.View.AccessibilityDelegate;
import android.view.accessibility.AccessibilityNodeInfo;
import android.view.accessibility.AccessibilityNodeInfo.AccessibilityAction;
import android.widget.ImageView;
import android.widget.TextView;
import com.android.dialer.calllog.database.Coalescer;
import com.android.dialer.calllog.model.CoalescedRow;
import com.android.dialer.calllog.ui.NewCallLogAdapter.PopCounts;
import com.android.dialer.calllog.ui.menu.NewCallLogMenu;
import com.android.dialer.calllogutils.CallLogEntryDescriptions;
import com.android.dialer.calllogutils.CallLogEntryText;
import com.android.dialer.calllogutils.CallLogRowActions;
import com.android.dialer.calllogutils.PhoneAccountUtils;
@@ -62,6 +66,7 @@ final class NewCallLogViewHolder extends RecyclerView.ViewHolder {
  private final ImageView assistedDialIcon;
  private final TextView phoneAccountView;
  private final ImageView menuButton;
  private final View callLogEntryRootView;

  private final Clock clock;
  private final RealtimeRowProcessor realtimeRowProcessor;
@@ -78,6 +83,7 @@ final class NewCallLogViewHolder extends RecyclerView.ViewHolder {
      PopCounts popCounts) {
    super(view);
    this.activity = activity;
    callLogEntryRootView = view;
    contactPhotoView = view.findViewById(R.id.contact_photo_view);
    primaryTextView = view.findViewById(R.id.primary_text);
    callCountTextView = view.findViewById(R.id.call_count);
@@ -107,6 +113,7 @@ final class NewCallLogViewHolder extends RecyclerView.ViewHolder {
    // what information we have, rather than an empty card. For example, if CP2 information needs to
    // be queried on the fly, we can still show the phone number until the contact name loads.
    displayRow(row);
    configA11yForRow(row);

    // Note: This leaks the view holder via the callback (which is an inner class), but this is OK
    // because we only create ~10 of them (and they'll be collected assuming all jobs finish).
@@ -142,6 +149,28 @@ final class NewCallLogViewHolder extends RecyclerView.ViewHolder {
    setOnClickListenerForMenuButon(row);
  }

  private void configA11yForRow(CoalescedRow row) {
    callLogEntryRootView.setContentDescription(
        CallLogEntryDescriptions.buildDescriptionForEntry(activity, clock, row));

    // Inform a11y users that double tapping an entry now makes a call.
    // This will instruct TalkBack to say "double tap to call" instead of
    // "double tap to activate".
    callLogEntryRootView.setAccessibilityDelegate(
        new AccessibilityDelegate() {
          @Override
          public void onInitializeAccessibilityNodeInfo(View host, AccessibilityNodeInfo info) {
            super.onInitializeAccessibilityNodeInfo(host, info);
            info.addAction(
                new AccessibilityAction(
                    AccessibilityNodeInfo.ACTION_CLICK,
                    activity
                        .getResources()
                        .getString(R.string.a11y_new_call_log_entry_tap_action)));
          }
        });
  }

  private void setNumberCalls(CoalescedRow row) {
    int numberCalls = row.getCoalescedIds().getCoalescedIdCount();
    if (numberCalls > 1) {
@@ -274,6 +303,12 @@ final class NewCallLogViewHolder extends RecyclerView.ViewHolder {

  private void setOnClickListenerForMenuButon(CoalescedRow row) {
    menuButton.setOnClickListener(NewCallLogMenu.createOnClickListener(activity, row));
    menuButton.setContentDescription(
        activity
            .getResources()
            .getString(
                R.string.a11y_new_call_log_entry_expand_menu,
                CallLogEntryText.buildPrimaryText(activity, row)));
  }

  private class RealtimeRowFutureCallback implements FutureCallback<CoalescedRow> {
+13 −3
Original line number Diff line number Diff line
@@ -30,13 +30,19 @@
      android:layout_marginEnd="8dp"
      android:layout_centerVertical="true"/>

  <!--
    A vertical linear layout of three rows: primary info, secondary info, and phone account info.
    It is marked as not important for a11y as we will set a more user-friendly content description
    for the entire entry view in Java code.
  -->
  <LinearLayout
      android:layout_width="wrap_content"
      android:layout_height="wrap_content"
      android:layout_centerVertical="true"
      android:layout_toEndOf="@+id/contact_photo_view"
      android:layout_toStartOf="@+id/menu_button"
      android:orientation="vertical">
      android:orientation="vertical"
      android:importantForAccessibility="noHideDescendants">

    <!-- 1st row: primary info -->
    <LinearLayout
@@ -134,6 +140,10 @@

  </LinearLayout>

  <!--
    The button to expand the bottom sheet for an entry.
    Its content description is set in Java code.
  -->
  <ImageView
      android:id="@+id/menu_button"
      android:layout_width="56dp"
@@ -141,8 +151,8 @@
      android:layout_alignParentEnd="true"
      android:layout_centerVertical="true"
      android:background="?android:attr/selectableItemBackgroundBorderless"
      android:contentDescription="@string/a11y_new_call_log_expand_menu_for_entry"
      android:scaleType="center"
      android:src="@drawable/quantum_ic_more_vert_vd_theme_24"
      android:tint="?colorIcon"/>
      android:tint="?colorIcon"
      tools:ignore="ContentDescription"/>
</RelativeLayout>
+13 −4
Original line number Diff line number Diff line
@@ -16,12 +16,21 @@
  -->

<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">

  <!--
    A string informing a11y users that activating a call log entry will place a call.
    Note: the word "call" here is a verb.
    [CHAR LIMIT=NONE]
  -->
  <string name="a11y_new_call_log_entry_tap_action">call</string>

  <!--
    A string to describe available action for accessibility user.
    It will be read as "expand menu for this call log entry".
    A string describing the menu button of a call log entry for a11y users.
    An example will be read as "expand call log menu for Jane Smith".
    [CHAR LIMIT=NONE]
  -->
  <string name="a11y_new_call_log_expand_menu_for_entry">
    Expand menu for this call log entry
  <string name="a11y_new_call_log_entry_expand_menu">
    Expand call log menu for <xliff:g example="Jane Smith" id="primaryTextForEntry">%1$s</xliff:g>
  </string>

  <!-- Header in call log to group calls from the current day.  [CHAR LIMIT=30] -->
+48 −37
Original line number Diff line number Diff line
@@ -36,13 +36,16 @@ public final class CallLogDates {
   *   if < 1 minute ago: "Just now";
   *   else if < 1 hour ago: time relative to now (e.g., "8 min ago");
   *   else if today: time (e.g., "12:15 PM");
   *   else if < 7 days: abbreviated day of week (e.g., "Wed");
   *   else if < 1 year: date with abbreviated month, day, but no year (e.g., "Jan 15");
   *   else: date with abbreviated month, day, and year (e.g., "Jan 15, 2018").
   *   else if < 7 days: day of week (e.g., "Wed");
   *   else if < 1 year: date with month, day, but no year (e.g., "Jan 15");
   *   else: date with month, day, and year (e.g., "Jan 15, 2018").
   * </pre>
   *
   * <p>Callers can decide whether to abbreviate date/time by specifying flag {@code
   * abbreviateDateTime}.
   */
  public static CharSequence newCallLogTimestampLabel(
      Context context, long nowMillis, long timestampMillis) {
      Context context, long nowMillis, long timestampMillis, boolean abbreviateDateTime) {
    // For calls logged less than 1 minute ago, display "Just now".
    if (nowMillis - timestampMillis < TimeUnit.MINUTES.toMillis(1)) {
      return context.getString(R.string.just_now);
@@ -50,16 +53,19 @@ public final class CallLogDates {

    // For calls logged less than 1 hour ago, display time relative to now (e.g., "8 min ago").
    if (nowMillis - timestampMillis < TimeUnit.HOURS.toMillis(1)) {
      return DateUtils.getRelativeTimeSpanString(
      return abbreviateDateTime
          ? DateUtils.getRelativeTimeSpanString(
                  timestampMillis,
                  nowMillis,
                  DateUtils.MINUTE_IN_MILLIS,
                  DateUtils.FORMAT_ABBREV_RELATIVE)
              .toString()
              // The platform method DateUtils#getRelativeTimeSpanString adds a dot ('.') after the
          // abbreviated time unit for some languages (e.g., "8 min. ago") but we prefer not to have
          // the dot.
          .replace(".", "");
              // abbreviated time unit for some languages (e.g., "8 min. ago") but we prefer not to
              // have the dot.
              .replace(".", "")
          : DateUtils.getRelativeTimeSpanString(
              timestampMillis, nowMillis, DateUtils.MINUTE_IN_MILLIS);
    }

    int dayDifference = getDayDifference(nowMillis, timestampMillis);
@@ -69,19 +75,19 @@ public final class CallLogDates {
      return DateUtils.formatDateTime(context, timestampMillis, DateUtils.FORMAT_SHOW_TIME);
    }

    // For calls logged within a week, display the abbreviated day of week (e.g., "Wed").
    // For calls logged within a week, display the day of week (e.g., "Wed").
    if (dayDifference < 7) {
      return formatDayOfWeek(context, timestampMillis);
      return formatDayOfWeek(context, timestampMillis, abbreviateDateTime);
    }

    // For calls logged within a year, display abbreviated month, day, but no year (e.g., "Jan 15").
    // For calls logged within a year, display month, day, but no year (e.g., "Jan 15").
    if (isWithinOneYear(nowMillis, timestampMillis)) {
      return formatAbbreviatedDate(context, timestampMillis, /* showYear = */ false);
      return formatDate(context, timestampMillis, /* showYear = */ false, abbreviateDateTime);
    }

    // For calls logged no less than one year ago, display abbreviated month, day, and year
    // For calls logged no less than one year ago, display month, day, and year
    // (e.g., "Jan 15, 2018").
    return formatAbbreviatedDate(context, timestampMillis, /* showYear = */ true);
    return formatDate(context, timestampMillis, /* showYear = */ true, abbreviateDateTime);
  }

  /**
@@ -106,36 +112,41 @@ public final class CallLogDates {
  }

  /**
   * Formats the provided timestamp (in milliseconds) into abbreviated day of week.
   * Formats the provided timestamp (in milliseconds) into the month, day, and optionally, year.
   *
   * <p>For example, returns a string like "Wed" or "Chor".
   * <p>For example, returns a string like "Jan 15" or "Jan 15, 2018".
   *
   * <p>For pre-N devices, the returned value may not start with a capital if the local convention
   * is to not capitalize day names. On N+ devices, the returned value is always capitalized.
   */
  private static CharSequence formatDayOfWeek(Context context, long timestamp) {
    return toTitleCase(
        DateUtils.formatDateTime(
            context, timestamp, DateUtils.FORMAT_SHOW_WEEKDAY | DateUtils.FORMAT_ABBREV_WEEKDAY));
  private static CharSequence formatDate(
      Context context, long timestamp, boolean showYear, boolean abbreviateDateTime) {
    int formatFlags = 0;
    if (abbreviateDateTime) {
      formatFlags |= DateUtils.FORMAT_ABBREV_MONTH;
    }
    if (!showYear) {
      formatFlags |= DateUtils.FORMAT_NO_YEAR;
    }

    return toTitleCase(DateUtils.formatDateTime(context, timestamp, formatFlags));
  }

  /**
   * Formats the provided timestamp (in milliseconds) into the month abbreviation, day, and
   * optionally, year.
   * Formats the provided timestamp (in milliseconds) into day of week.
   *
   * <p>For example, returns a string like "Jan 15" or "Jan 15, 2018".
   * <p>For example, returns a string like "Wed" or "Chor".
   *
   * <p>For pre-N devices, the returned value may not start with a capital if the local convention
   * is to not capitalize day names. On N+ devices, the returned value is always capitalized.
   */
  private static CharSequence formatAbbreviatedDate(
      Context context, long timestamp, boolean showYear) {
    int flags = DateUtils.FORMAT_ABBREV_MONTH;
    if (!showYear) {
      flags |= DateUtils.FORMAT_NO_YEAR;
    }

    return toTitleCase(DateUtils.formatDateTime(context, timestamp, flags));
  private static CharSequence formatDayOfWeek(
      Context context, long timestamp, boolean abbreviateDateTime) {
    int formatFlags =
        abbreviateDateTime
            ? (DateUtils.FORMAT_SHOW_WEEKDAY | DateUtils.FORMAT_ABBREV_WEEKDAY)
            : DateUtils.FORMAT_SHOW_WEEKDAY;
    return toTitleCase(DateUtils.formatDateTime(context, timestamp, formatFlags));
  }

  private static CharSequence toTitleCase(CharSequence value) {
+154 −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 com.android.dialer.calllogutils;

import android.content.Context;
import android.provider.CallLog.Calls;
import android.support.annotation.PluralsRes;
import android.telecom.PhoneAccountHandle;
import android.text.TextUtils;
import com.android.dialer.calllog.model.CoalescedRow;
import com.android.dialer.telecom.TelecomUtil;
import com.android.dialer.time.Clock;
import com.google.common.collect.Collections2;
import java.util.List;

/** Builds descriptions of call log entries for accessibility users. */
public final class CallLogEntryDescriptions {

  private CallLogEntryDescriptions() {}

  /**
   * Builds the content description for a call log entry.
   *
   * <p>The description is of format<br>
   * {primary description}, {secondary description}, {phone account description}.
   *
   * <ul>
   *   <li>The primary description depends on the number of calls in the entry. For example:<br>
   *       "1 answered call from Jane Smith", or<br>
   *       "2 calls, the latest is an answered call from Jane Smith".
   *   <li>The secondary description is the same as the secondary text for the call log entry,
   *       except that date/time is not abbreviated. For example:<br>
   *       "mobile, 11 minutes ago".
   *   <li>The phone account description is of format "on {phone_account_label}, via {number}". For
   *       example:<br>
   *       "on SIM 1, via 6502531234".<br>
   *       Note that the phone account description will be empty if the device has only one SIM.
   * </ul>
   *
   * <p>An example of the full description can be:<br>
   * "2 calls, the latest is an answered call from Jane Smith, mobile, 11 minutes ago, on SIM 1, via
   * 6502531234".
   */
  public static CharSequence buildDescriptionForEntry(
      Context context, Clock clock, CoalescedRow row) {

    // Build the primary description.
    // Examples:
    //   (1) For an entry containing only 1 call:
    //         "1 missed call from James Smith".
    //   (2) For entries containing multiple calls:
    //         "2 calls, the latest is a missed call from Jame Smith".
    CharSequence primaryDescription =
        context
            .getResources()
            .getQuantityString(
                getPrimaryDescriptionResIdForCallType(row),
                row.getCoalescedIds().getCoalescedIdCount(),
                row.getCoalescedIds().getCoalescedIdCount(),
                CallLogEntryText.buildPrimaryText(context, row));

    // Build the secondary description.
    // An example: "mobile, 11 minutes ago".
    CharSequence secondaryDescription =
        joinSecondaryTextComponents(
            CallLogEntryText.buildSecondaryTextListForEntries(
                context, clock, row, /* abbreviateDateTime = */ false));

    // Build the phone account description.
    // Note that this description can be an empty string.
    CharSequence phoneAccountDescription = buildPhoneAccountDescription(context, row);

    return TextUtils.isEmpty(phoneAccountDescription)
        ? context
            .getResources()
            .getString(
                R.string.a11y_new_call_log_entry_full_description_without_phone_account_info,
                primaryDescription,
                secondaryDescription)
        : context
            .getResources()
            .getString(
                R.string.a11y_new_call_log_entry_full_description_with_phone_account_info,
                primaryDescription,
                secondaryDescription,
                phoneAccountDescription);
  }

  private static @PluralsRes int getPrimaryDescriptionResIdForCallType(CoalescedRow row) {
    switch (row.getCallType()) {
      case Calls.INCOMING_TYPE:
      case Calls.ANSWERED_EXTERNALLY_TYPE:
        return R.plurals.a11y_new_call_log_entry_answered_call;
      case Calls.OUTGOING_TYPE:
        return R.plurals.a11y_new_call_log_entry_outgoing_call;
      case Calls.MISSED_TYPE:
        return R.plurals.a11y_new_call_log_entry_missed_call;
      case Calls.VOICEMAIL_TYPE:
        throw new IllegalStateException("Voicemails not expected in call log");
      case Calls.BLOCKED_TYPE:
        return R.plurals.a11y_new_call_log_entry_blocked_call;
      default:
        // It is possible for users to end up with calls with unknown call types in their
        // call history, possibly due to 3rd party call log implementations (e.g. to
        // distinguish between rejected and missed calls). Instead of crashing, just
        // assume that all unknown call types are missed calls.
        return R.plurals.a11y_new_call_log_entry_missed_call;
    }
  }

  private static CharSequence buildPhoneAccountDescription(Context context, CoalescedRow row) {
    PhoneAccountHandle phoneAccountHandle =
        TelecomUtil.composePhoneAccountHandle(
            row.getPhoneAccountComponentName(), row.getPhoneAccountId());
    if (phoneAccountHandle == null) {
      return "";
    }

    String phoneAccountLabel = PhoneAccountUtils.getAccountLabel(context, phoneAccountHandle);
    if (TextUtils.isEmpty(phoneAccountLabel)) {
      return "";
    }

    if (TextUtils.isEmpty(row.getNumber().getNormalizedNumber())) {
      return "";
    }

    return context
        .getResources()
        .getString(
            R.string.a11y_new_call_log_entry_phone_account,
            phoneAccountLabel,
            row.getNumber().getNormalizedNumber());
  }

  private static CharSequence joinSecondaryTextComponents(List<CharSequence> components) {
    return TextUtils.join(
        ", ", Collections2.filter(components, (text) -> !TextUtils.isEmpty(text)));
  }
}
Loading