ComplicationTextUtils.java

/*
 * Copyright 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 androidx.wear.complications;

import static java.util.concurrent.TimeUnit.DAYS;
import static java.util.concurrent.TimeUnit.HOURS;
import static java.util.concurrent.TimeUnit.MINUTES;
import static java.util.concurrent.TimeUnit.SECONDS;

import android.text.format.DateFormat;

import androidx.annotation.NonNull;
import androidx.annotation.RestrictTo;

import java.text.SimpleDateFormat;
import java.util.Date;
import java.util.Locale;

/**
 * Utilities to obtain the best date or time format for a given locale.
 *
 * @hide
 */
@RestrictTo(RestrictTo.Scope.LIBRARY)
public class ComplicationTextUtils {

    private ComplicationTextUtils() {}

    private static final int TEST_STEPS = 13;
    private static final int SHORT_TEXT_MAX_LENGTH = 7;

    /**
     * The amount of time to advance at each test step if a given symbol appears in a format
     * skeleton.
     */
    private static final TimeUnitMapping[] TIME_UNIT_MAPPINGS = {
        new TimeUnitMapping(SECONDS.toMillis(47), "S", "s"),
        new TimeUnitMapping(MINUTES.toMillis(47), "m"),
        new TimeUnitMapping(HOURS.toMillis(5), "H", "K", "h", "k", "j", "J"),
        new TimeUnitMapping(DAYS.toMillis(1), "D", "E", "F", "c", "d", "g"),
        new TimeUnitMapping(DAYS.toMillis(27), "M", "L")
    };

    /** Formats to use instead of the return value of getBestDateTimePattern. */
    private static final FormatMapping[] FORMAT_MAPPINGS = {
        // In Finnish and German, date should be followed by "." but the best format does not
        // include this when date is on its own.
        new FormatMapping("fi", "d", "d."),
        new FormatMapping("fi", "dd", "dd."),
        new FormatMapping("de", "d", "d."),
        new FormatMapping("de", "dd", "dd."),

        // German MMM gives LLL as best format, which returns e.g. "Jan", whereas MMM returns "Jan."
        // Same for Norwegian.
        new FormatMapping("de", "MMM", "MMM"),
        new FormatMapping("no", "MMM", "MMM"),
        new FormatMapping("nb", "MMM", "MMM"),

        // Norwegian time formats should use a dot instead of a colon
        new FormatMapping("no", "HHmm", "HH.mm"),
        new FormatMapping("no", "hmm", "h.mm a"),
        new FormatMapping("nb", "HHmm", "HH.mm"),
        new FormatMapping("nb", "hmm", "h.mm a")
    };

    /**
     * Returns a pattern suitable for use with {@link SimpleDateFormat} to represent a time value in
     * the given locale. The resulting text should fit within a short text field for any input time
     * value. This may be achieved by showing a 12h time without an am/pm indicator in languages in
     * which that indicator would be too long.
     */
    @NonNull
    public static String shortTextTimeFormat(@NonNull Locale locale, boolean use24Hour) {
        if (use24Hour) {
            return bestShortTextDateFormat(locale, new String[] {"HHmm"}, "HH:mm");
        }

        // For 12h clock, the dayPeriod (am/pm) part is often problematic. Try bestDateTimePattern
        // first...
        long timeStep = MINUTES.toMillis(97);
        String pattern = DateFormat.getBestDateTimePattern(locale, "hmm");
        if (isShortEnough(locale, pattern, timeStep)) {
            return pattern;
        }

        // Too long - try removing the space before am/pm, if there is one.
        String patternWithoutSpaceBeforeAmPm = pattern.replace(" a", "a");
        if (!pattern.equals(patternWithoutSpaceBeforeAmPm)
                && isShortEnough(locale, patternWithoutSpaceBeforeAmPm, timeStep)) {
            return patternWithoutSpaceBeforeAmPm;
        }

        // Still too long - try stripping the am/pm part entirely.
        String patternWithoutAmPm = pattern.replace("a", "").trim();
        if (!pattern.equals(patternWithoutAmPm)
                && isShortEnough(locale, patternWithoutAmPm, timeStep)) {
            return patternWithoutAmPm;
        }

        // Still too long. Fall back.
        return "h:mm";
    }

    /**
     * Returns a pattern suitable for use with {@link SimpleDateFormat} to represent a day and
     * month, in the given locale, e.g. "25 Jan" in en-GB. The resulting text should fit within a
     * short text field for any input time value.
     */
    @NonNull
    public static String shortTextDayMonthFormat(@NonNull Locale locale) {
        return bestShortTextDateFormat(locale, new String[] {"MMMd", "MMd", "Md"}, "d/MM");
    }

    /**
     * Returns a pattern suitable for use with {@link SimpleDateFormat} to represent a month, in the
     * given locale, e.g. "Jan" in en-GB. The resulting text should fit within a short text field
     * for any input time value.
     */
    @NonNull
    public static String shortTextMonthFormat(@NonNull Locale locale) {
        return bestShortTextDateFormat(locale, new String[] {"MMM", "MM", "M"}, "MM");
    }

    /**
     * Returns a pattern suitable for use with {@link SimpleDateFormat} to represent the day part of
     * a given date, in the given locale, e.g. "25" in en-GB, or "25." in de. The resulting text
     * should fit within a short text field for any input time value.
     */
    @NonNull
    public static String shortTextDayOfMonthFormat(@NonNull Locale locale) {
        return bestShortTextDateFormat(locale, new String[] {"dd", "d"}, "dd");
    }

    /**
     * Returns a pattern suitable for use with {@link SimpleDateFormat} to represent the day of the
     * week, in the given locale, e.g. "Thu" in en-GB. The resulting text should fit within a short
     * text field for any input time value.
     */
    @NonNull
    public static String shortTextDayOfWeekFormat(@NonNull Locale locale) {
        return bestShortTextDateFormat(locale, new String[] {"EEE", "EEEEEE", "EEEEE"}, "EEEEE");
    }

    /**
     * Returns a date format pattern that will produce results that fit into a short text field when
     * used with the given locale. The skeletons will be passed to {@link
     * DateFormat#getBestDateTimePattern}, and the resulting pattern will be tested for a number of
     * times to see if the text will fit. The first result that fits for all the test times will be
     * returned. If none of the skeletons produces a suitable result, the fallback will be returned
     * instead.
     */
    @NonNull
    private static String bestShortTextDateFormat(
            @NonNull Locale locale, @NonNull String[] skeletons, @NonNull String fallback) {
        for (String skeleton : skeletons) {
            String pattern = null;
            for (FormatMapping mapping : FORMAT_MAPPINGS) {
                if (locale.getLanguage().equals(mapping.mLanguage)
                        && skeleton.equals(mapping.mSkeleton)) {
                    pattern = mapping.mPattern;
                    break;
                }
            }
            if (pattern == null) {
                pattern = DateFormat.getBestDateTimePattern(locale, skeleton);
            }
            if (isShortEnough(locale, pattern, timeStepForSkeleton(skeleton))) {
                return pattern;
            }
        }
        return fallback;
    }

    private static boolean isShortEnough(
            @NonNull Locale locale, @NonNull String pattern, long timeStep) {
        SimpleDateFormat format = new SimpleDateFormat(pattern, locale);
        long testTime = System.currentTimeMillis();

        for (int i = 0; i < TEST_STEPS; i++) {
            if (format.format(new Date(testTime)).length() > SHORT_TEXT_MAX_LENGTH) {
                return false;
            }
            testTime += timeStep;
        }
        return true;
    }

    private static long timeStepForSkeleton(@NonNull String skeleton) {
        long timeStep = 0;
        for (TimeUnitMapping timeMapping : TIME_UNIT_MAPPINGS) {
            for (String symbol : timeMapping.mStrings) {
                if (skeleton.contains(symbol)) {
                    timeStep += timeMapping.mTimeUnit;
                    break;
                }
            }
        }
        return timeStep;
    }

    private static class FormatMapping {
        final String mLanguage;
        final String mSkeleton;
        final String mPattern;

        FormatMapping(String language, String skeleton, String pattern) {
            this.mLanguage = language;
            this.mSkeleton = skeleton;
            this.mPattern = pattern;
        }
    }

    private static class TimeUnitMapping {
        final long mTimeUnit;
        final String[] mStrings;

        TimeUnitMapping(long timeUnit, String... symbols) {
            this.mTimeUnit = timeUnit;
            this.mStrings = symbols;
        }
    }
}