From 19a7c0eda9730798100994e0b5a6e99197f04f3d Mon Sep 17 00:00:00 2001 From: linyuh Date: Wed, 23 May 2018 16:41:50 -0700 Subject: Better a11y for new call log entries. Bug: 70989658 Test: CallLogDatesTest, CallLogEntryDescriptionsTest, NewCallLogViewHolderTest PiperOrigin-RevId: 197811739 Change-Id: I0f9d1e79d8e687efffbb1dac01aaf6fa26a45f6a --- .../dialer/calllog/ui/NewCallLogViewHolder.java | 35 +++++ .../calllog/ui/res/layout/new_call_log_entry.xml | 16 ++- .../dialer/calllog/ui/res/values/strings.xml | 17 ++- .../android/dialer/calllogutils/CallLogDates.java | 85 +++++++----- .../calllogutils/CallLogEntryDescriptions.java | 154 +++++++++++++++++++++ .../dialer/calllogutils/CallLogEntryText.java | 71 +++++++--- .../dialer/calllogutils/res/values/strings.xml | 63 +++++++++ .../voicemail/listui/VoicemailEntryText.java | 5 +- 8 files changed, 378 insertions(+), 68 deletions(-) create mode 100644 java/com/android/dialer/calllogutils/CallLogEntryDescriptions.java diff --git a/java/com/android/dialer/calllog/ui/NewCallLogViewHolder.java b/java/com/android/dialer/calllog/ui/NewCallLogViewHolder.java index 4def69cf9..3b21a60de 100644 --- a/java/com/android/dialer/calllog/ui/NewCallLogViewHolder.java +++ b/java/com/android/dialer/calllog/ui/NewCallLogViewHolder.java @@ -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 { diff --git a/java/com/android/dialer/calllog/ui/res/layout/new_call_log_entry.xml b/java/com/android/dialer/calllog/ui/res/layout/new_call_log_entry.xml index 0acd8155f..726c53bc1 100644 --- a/java/com/android/dialer/calllog/ui/res/layout/new_call_log_entry.xml +++ b/java/com/android/dialer/calllog/ui/res/layout/new_call_log_entry.xml @@ -30,13 +30,19 @@ android:layout_marginEnd="8dp" android:layout_centerVertical="true"/> + + android:orientation="vertical" + android:importantForAccessibility="noHideDescendants"> + + android:tint="?colorIcon" + tools:ignore="ContentDescription"/> diff --git a/java/com/android/dialer/calllog/ui/res/values/strings.xml b/java/com/android/dialer/calllog/ui/res/values/strings.xml index 3f6462c7b..112044f6e 100644 --- a/java/com/android/dialer/calllog/ui/res/values/strings.xml +++ b/java/com/android/dialer/calllog/ui/res/values/strings.xml @@ -16,12 +16,21 @@ --> + + + call + - - Expand menu for this call log entry + + Expand call log menu for %1$s diff --git a/java/com/android/dialer/calllogutils/CallLogDates.java b/java/com/android/dialer/calllogutils/CallLogDates.java index 2c332901c..9c04c05f7 100644 --- a/java/com/android/dialer/calllogutils/CallLogDates.java +++ b/java/com/android/dialer/calllogutils/CallLogDates.java @@ -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"). * + * + *

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( - 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(".", ""); + 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(".", "") + : 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. * - *

For example, returns a string like "Wed" or "Chor". + *

For example, returns a string like "Jan 15" or "Jan 15, 2018". * *

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. * - *

For example, returns a string like "Jan 15" or "Jan 15, 2018". + *

For example, returns a string like "Wed" or "Chor". * *

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) { diff --git a/java/com/android/dialer/calllogutils/CallLogEntryDescriptions.java b/java/com/android/dialer/calllogutils/CallLogEntryDescriptions.java new file mode 100644 index 000000000..244087989 --- /dev/null +++ b/java/com/android/dialer/calllogutils/CallLogEntryDescriptions.java @@ -0,0 +1,154 @@ +/* + * 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. + * + *

The description is of format
+ * {primary description}, {secondary description}, {phone account description}. + * + *

    + *
  • The primary description depends on the number of calls in the entry. For example:
    + * "1 answered call from Jane Smith", or
    + * "2 calls, the latest is an answered call from Jane Smith". + *
  • The secondary description is the same as the secondary text for the call log entry, + * except that date/time is not abbreviated. For example:
    + * "mobile, 11 minutes ago". + *
  • The phone account description is of format "on {phone_account_label}, via {number}". For + * example:
    + * "on SIM 1, via 6502531234".
    + * Note that the phone account description will be empty if the device has only one SIM. + *
+ * + *

An example of the full description can be:
+ * "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 components) { + return TextUtils.join( + ", ", Collections2.filter(components, (text) -> !TextUtils.isEmpty(text))); + } +} diff --git a/java/com/android/dialer/calllogutils/CallLogEntryText.java b/java/com/android/dialer/calllogutils/CallLogEntryText.java index acf8ef932..895497f0f 100644 --- a/java/com/android/dialer/calllogutils/CallLogEntryText.java +++ b/java/com/android/dialer/calllogutils/CallLogEntryText.java @@ -26,6 +26,7 @@ import com.android.dialer.time.Clock; import com.google.common.base.Optional; import com.google.common.collect.Collections2; import java.util.ArrayList; +import java.util.Collections; import java.util.List; /** @@ -76,45 +77,69 @@ public final class CallLogEntryText { } /** - * The secondary text to show in the main call log entry list. + * The secondary text to be shown in the main call log entry list. + * + *

This method first obtains a list of strings to be shown in order and then concatenates them + * with " • ". + * + *

Examples: + * + *

    + *
  • Mobile, Duo video • 10 min ago + *
  • Spam • Mobile • Now + *
  • Blocked • Spam • Mobile • Now + *
+ * + * @see #buildSecondaryTextListForEntries(Context, Clock, CoalescedRow, boolean) for details. + */ + public static CharSequence buildSecondaryTextForEntries( + Context context, Clock clock, CoalescedRow row) { + return joinSecondaryTextComponents( + buildSecondaryTextListForEntries(context, clock, row, /* abbreviateDateTime = */ true)); + } + + /** + * Returns a list of strings to be shown in order as the main call log entry's secondary text. * *

Rules: * *

    - *
  • An emergency number: Date + *
  • An emergency number: [{Date}] *
  • Number - not blocked, call - not spam: - *

    $Label(, Duo video|Carrier video)?|$Location • Date + *

    [{$Label(, Duo video|Carrier video)?|$Location}, {Date}] *

  • Number - blocked, call - not spam: - *

    Blocked • $Label(, Duo video|Carrier video)?|$Location • Date + *

    ["Blocked", {$Label(, Duo video|Carrier video)?|$Location}, {Date}] *

  • Number - not blocked, call - spam: - *

    Spam • $Label(, Duo video|Carrier video)? • Date + *

    ["Spam", {$Label(, Duo video|Carrier video)?}, {Date}] *

  • Number - blocked, call - spam: - *

    Blocked • Spam • $Label(, Duo video|Carrier video)? • Date + *

    ["Blocked, Spam", {$Label(, Duo video|Carrier video)?}, {Date}] *

* *

Examples: * *

    - *
  • Mobile, Duo video • Now - *
  • Duo video • 10 min ago - *
  • Mobile • 11:45 PM - *
  • Mobile • Sun - *
  • Blocked • Mobile, Duo video • Now - *
  • Blocked • Brooklyn, NJ • 10 min ago - *
  • Spam • Mobile • Now - *
  • Spam • Now - *
  • Blocked • Spam • Mobile • Now - *
  • Brooklyn, NJ • Jan 15 + *
  • ["Mobile, Duo video", "Now"] + *
  • ["Duo video", "10 min ago"] + *
  • ["Mobile", "11:45 PM"] + *
  • ["Mobile", "Sun"] + *
  • ["Blocked", "Mobile, Duo video", "Now"] + *
  • ["Blocked", "Brooklyn, NJ", "10 min ago"] + *
  • ["Spam", "Mobile", "Now"] + *
  • ["Spam", "Now"] + *
  • ["Blocked", "Spam", "Mobile", "Now"] + *
  • ["Brooklyn, NJ", "Jan 15"] *
* - *

See {@link CallLogDates#newCallLogTimestampLabel(Context, long, long)} for date rules. + *

See {@link CallLogDates#newCallLogTimestampLabel(Context, long, long, boolean)} for date + * rules. */ - public static CharSequence buildSecondaryTextForEntries( - Context context, Clock clock, CoalescedRow row) { + static List buildSecondaryTextListForEntries( + Context context, Clock clock, CoalescedRow row, boolean abbreviateDateTime) { // For emergency numbers, the secondary text should contain only the timestamp. if (row.getNumberAttributes().getIsEmergencyNumber()) { - return CallLogDates.newCallLogTimestampLabel( - context, clock.currentTimeMillis(), row.getTimestamp()); + return Collections.singletonList( + CallLogDates.newCallLogTimestampLabel( + context, clock.currentTimeMillis(), row.getTimestamp(), abbreviateDateTime)); } List components = new ArrayList<>(); @@ -130,8 +155,8 @@ public final class CallLogEntryText { components.add( CallLogDates.newCallLogTimestampLabel( - context, clock.currentTimeMillis(), row.getTimestamp())); - return joinSecondaryTextComponents(components); + context, clock.currentTimeMillis(), row.getTimestamp(), abbreviateDateTime)); + return components; } /** diff --git a/java/com/android/dialer/calllogutils/res/values/strings.xml b/java/com/android/dialer/calllogutils/res/values/strings.xml index e476bdd6c..52b6d3408 100644 --- a/java/com/android/dialer/calllogutils/res/values/strings.xml +++ b/java/com/android/dialer/calllogutils/res/values/strings.xml @@ -145,4 +145,67 @@ Spam + + + + %1$d missed call from %2$s + %1$d calls, the latest is a missed call from %2$s + + + + + %1$d answered call from %2$s + %1$d calls, the latest is an answered call from %2$s + + + + + %1$d outgoing call to %2$s + %1$d calls, the latest is an outgoing call to %2$s + + + + + %1$d blocked call from %2$s + %1$d calls, the latest is a blocked call from %2$s + + + + + + on %1$s, + via %2$s + + + + + %1$s, %2$s. + + + + + %1$s, %2$s, %3$s. + diff --git a/java/com/android/dialer/voicemail/listui/VoicemailEntryText.java b/java/com/android/dialer/voicemail/listui/VoicemailEntryText.java index 973f9b1a8..dd53dffc5 100644 --- a/java/com/android/dialer/voicemail/listui/VoicemailEntryText.java +++ b/java/com/android/dialer/voicemail/listui/VoicemailEntryText.java @@ -80,7 +80,10 @@ public class VoicemailEntryText { } secondaryText.append( CallLogDates.newCallLogTimestampLabel( - context, clock.currentTimeMillis(), voicemailEntry.getTimestamp())); + context, + clock.currentTimeMillis(), + voicemailEntry.getTimestamp(), + /* abbreviateDateTime = */ true)); long duration = voicemailEntry.getDuration(); if (duration >= 0) { -- cgit v1.2.3