/*
* 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.ongoing;
import android.content.Context;
import android.text.SpannableStringBuilder;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.VisibleForTesting;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
/**
* Base class to represent the status of an Ongoing Activity and render it.
* <p>
* A status is composed of Parts, and they are joined together with a template.
* <p>
* Note that for backwards compatibility reasons the code rendering this status message may not
* have all of the [Part] classes that are available in later versions of the library.
* Templates that do not have values for all of the named parts will not be used.
* The template list will be iterated through looking for the first template with all matching named
* parts available, this will be selected for rendering the status.
* <p>
* To provide for backwards compatibility, you should provide one (or more) fallback templates which
* use status parts from earlier versions of the API. e.g. TextPart, TimerPart & StopwatchPart
* <p>
* The status and part classes here use timestamps for updating the displayed representation of the
* status, in cases when this is needed (chronometers), as returned by
* {@link android.os.SystemClock#elapsedRealtime()}
*/
public final class Status implements TimeDependentText {
@NonNull
final List<CharSequence> mTemplates;
@NonNull
private final Map<String, StatusPart> mParts;
/**
* Abstract class to represent An Ongoing activity status or part of it.
* <p>
* Parts are used to create complex statuses, that may contain several timers, placeholders for
* text, etc. They may also be used to convey information to the system about this Ongoing
* Activity.
*/
public abstract static class Part implements TimeDependentText {
// Hide constructor.
Part() {
}
@Nullable
StatusPart toVersionedParcelable() {
return null;
}
@Nullable
static Part fromVersionedParcelable(@Nullable StatusPart vp) {
if (vp == null) {
return null;
}
if (vp instanceof TextStatusPart) {
return new TextPart((TextStatusPart) vp);
} else if (vp instanceof TimerStatusPart) {
TimerStatusPart tsp = (TimerStatusPart) vp;
return tsp.mCountDown ? new TimerPart(tsp) : new StopwatchPart(tsp);
} else {
return null;
}
}
}
/**
* An Ongoing activity status (or part of it) representing a plain, static text.
* <p>
* Available since wear-ongoing:1.0.0
*/
public static final class TextPart extends Part {
@NonNull
private final TextStatusPart mPart;
TextPart(@NonNull TextStatusPart part) {
mPart = part;
}
/**
* Create a Part representing a static text.
*/
public TextPart(@NonNull String str) {
mPart = new TextStatusPart(str);
}
@Override
@NonNull
StatusPart toVersionedParcelable() {
return mPart;
}
/**
* See {@link TimeDependentText#getText(Context, long)}
*/
@NonNull
@Override
public CharSequence getText(@NonNull Context context, long timeNowMillis) {
return mPart.getText(context, timeNowMillis);
}
/**
* See {@link TimeDependentText#getNextChangeTimeMillis(long)}
*/
@Override
public long getNextChangeTimeMillis(long fromTimeMillis) {
return mPart.getNextChangeTimeMillis(fromTimeMillis);
}
@Override
public int hashCode() {
return mPart.hashCode();
}
@Override
public boolean equals(@Nullable Object obj) {
if (!(obj instanceof TextPart)) return false;
return mPart.equals(((TextPart) obj).mPart);
}
}
/**
* Base class for {@link TimerPart} and {@link StopwatchPart}, defines the getters but can't
* be created directly, create one of those instead.
*/
public abstract static class TimerOrStopwatchPart extends Part {
@NonNull
private final TimerStatusPart mPart;
TimerOrStopwatchPart(@NonNull TimerStatusPart part) {
mPart = part;
}
/**
* @return the time at which this Timer or Stopwatch will display 0, will usually be in the
* past for a stopwatch and in the future for timers.
*/
public long getTimeZeroMillis() {
return mPart.mTimeZeroMillis;
}
/**
* @return {@code false} if this is a stopwatch or {@code true} if this is a timer.
*/
public boolean isCountDown() {
return mPart.mCountDown;
}
/**
* Determines if this Timer or Stopwatch is paused. i.e. the display representation will
* not change over time.
*
* @return {@code true} if this is paused, {@code false} if it's running.
*/
public boolean isPaused() {
return mPart.isPaused();
}
/**
* @return the timestamp of the time when this was paused. Use
* {@link #isPaused()} to determine if this is paused or not.
*/
public long getPausedAtMillis() {
return mPart.mPausedAtMillis;
}
/**
* Determines if this has a total duration set.
*
* @return {@code true} if this the total duration was set, {@code false} if not.
*/
public boolean hasTotalDuration() {
return mPart.mTotalDurationMillis >= 0L;
}
/**
* @return the total duration of this timer/stopwatch, if set. Use
* {@link #hasTotalDuration()} to determine if this has a duration set.
*/
public long getTotalDurationMillis() {
return mPart.mTotalDurationMillis;
}
@Override
@NonNull
StatusPart toVersionedParcelable() {
return mPart;
}
@Override
public int hashCode() {
return mPart.hashCode();
}
@Override
public boolean equals(@Nullable Object obj) {
if (!(obj instanceof TimerOrStopwatchPart)) return false;
return mPart.equals(((TimerOrStopwatchPart) obj).mPart);
}
/**
* See {@link TimeDependentText#getText(Context, long)}
*/
@NonNull
@Override
public CharSequence getText(@NonNull Context context, long timeNowMillis) {
return mPart.getText(context, timeNowMillis);
}
/**
* See {@link TimeDependentText#getNextChangeTimeMillis(long)}
*/
@Override
public long getNextChangeTimeMillis(long fromTimeMillis) {
return mPart.getNextChangeTimeMillis(fromTimeMillis);
}
}
/**
* An Ongoing activity status (or part of it) representing a timer.
* <p>
* Available since wear-ongoing:1.0.0
*/
public static final class TimerPart extends TimerOrStopwatchPart {
TimerPart(@NonNull TimerStatusPart part) {
super(part);
}
/**
* Create a Part representing a timer.
*
* @param timeZeroMillis timestamp of the time at the future in which this Timer
* should display 0.
* @param pausedAtMillis timestamp of the time when this timer was paused. Or
* {@code -1L} if this timer is running.
* @param totalDurationMillis total duration of this timer, useful to display as a
* progress bar or similar.
*/
public TimerPart(long timeZeroMillis, long pausedAtMillis,
long totalDurationMillis) {
super(new TimerStatusPart(
timeZeroMillis,
/* countDown = */ true,
pausedAtMillis,
totalDurationMillis
));
}
/**
* Create a Part representing a timer.
*
* @param timeZeroMillis timestamp of the time at the future in which this Timer
* should display 0.
* @param pausedAtMillis timestamp of the time when this timer was paused. Or
* {@code -1L} if this timer is running.
*/
public TimerPart(long timeZeroMillis, long pausedAtMillis) {
this(timeZeroMillis, pausedAtMillis, TimerStatusPart.LONG_DEFAULT);
}
/**
* Create a Part representing a timer.
*
* @param timeZeroMillis timestamp of the time at the future in which this Timer
* should display 0.
*/
public TimerPart(long timeZeroMillis) {
this(timeZeroMillis, TimerStatusPart.LONG_DEFAULT);
}
}
/**
* An Ongoing activity status (or part of it) representing a stopwatch
* <p>
* Available since wear-ongoing:1.0.0
*/
public static final class StopwatchPart extends TimerOrStopwatchPart {
StopwatchPart(@NonNull TimerStatusPart part) {
super(part);
}
/**
* Create a Part representing a stopwatch.
*
* @param timeZeroMillis timestamp of the time at which this stopwatch started
* running.
* @param pausedAtMillis timestamp of the time when this stopwatch was paused. Or
* {@code -1L} if this stopwatch is running.
* @param totalDurationMillis total duration of this stopwatch, useful to display as a
* progress bar or similar.
*/
public StopwatchPart(long timeZeroMillis, long pausedAtMillis, long totalDurationMillis) {
super(new TimerStatusPart(
timeZeroMillis,
/* countDown = */ false,
pausedAtMillis,
totalDurationMillis
));
}
/**
* Create a Part representing a stopwatch.
*
* @param timeZeroMillis timestamp of the time at which this stopwatch started
* running.
* @param pausedAtMillis timestamp of the time when this stopwatch was paused. Or
* {@code -1L} if this stopwatch is running.
*/
public StopwatchPart(long timeZeroMillis, long pausedAtMillis) {
this(timeZeroMillis, pausedAtMillis, TimerStatusPart.LONG_DEFAULT);
}
/**
* Create a Part representing a stopwatch.
*
* @param timeZeroMillis timestamp of the time at which this stopwatch started
* running.
*/
public StopwatchPart(long timeZeroMillis) {
this(timeZeroMillis, TimerStatusPart.LONG_DEFAULT);
}
}
// Name of the {@link StatusPart} created when using {@link OngoingActivityStatus.forPart()}
private static final String DEFAULT_STATUS_PART_NAME = "defaultStatusPartName";
// Basic constructor used by the Builder
@VisibleForTesting
Status(
@Nullable List<CharSequence> templates,
@NonNull Map<String, StatusPart> parts
) {
mTemplates = templates;
mParts = parts;
}
OngoingActivityStatus toVersionedParcelable() {
return new OngoingActivityStatus(mTemplates, mParts);
}
static Status fromVersionedParcelable(OngoingActivityStatus vp) {
return new Status(vp.mTemplates, vp.mParts);
}
/**
* Convenience method for creating a Status with no template and a single Part.
*
* @param part The only Part that composes this status.
* @return A new {@link Status} with just one Part.
*/
@NonNull
public static Status forPart(@NonNull Part part) {
// Create an OngoingActivityStatus using only this part and the default template.
return new Status.Builder().addPart(DEFAULT_STATUS_PART_NAME, part).build();
}
/**
* Helper to Build OngoingActivityStatus instances.
*
* Templates can be specified, to specify how to render the parts and any surrounding
* text/format.
* If no template is specified, a default template that concatenates all parts separated
* by space is used.
*/
public static final class Builder {
private List<CharSequence> mTemplates = new ArrayList<>();
private CharSequence mDefaultTemplate = "";
private Map<String, StatusPart> mParts = new HashMap<>();
public Builder() {
}
/**
* Add a template to use for this status. Placeholders can be defined with #name#
* To produce a '#', use '##' in the template.
* If multiple templates are specified, the first one (in the order they where added by
* calling this method) that has all required fields is used.
* If no template is specified, a default template that concatenates all parts separated
* by space is used.
*
* @param template the template to be added
* @return this builder, to chain calls.
*/
@NonNull
public Builder addTemplate(@NonNull CharSequence template) {
mTemplates.add(template);
return this;
}
/**
* Add a part to be inserted in the placeholders.
*
* @param name the name of this part. In the template, use this name surrounded by '#'
* to reference it, e.g. here "track" and in the template "#track#"
* @param part The part that will be rendered in the specified position/s in the template.
* @return this builder, to chain calls.
*/
@NonNull
@SuppressWarnings("MissingGetterMatchingBuilder")
// We don't want a getter getParts()
public Builder addPart(@NonNull String name, @NonNull Part part) {
mParts.put(name, part.toVersionedParcelable());
mDefaultTemplate += (mDefaultTemplate.length() > 0 ? " " : "") + "#" + name + "#";
return this;
}
/**
* Build an OngoingActivityStatus with the given parameters.
*
* @return the built OngoingActivityStatus
*/
@NonNull
public Status build() {
List<CharSequence> templates = mTemplates.isEmpty() ? Arrays.asList(mDefaultTemplate)
: mTemplates;
// Verify that the last template can be rendered by every SysUI.
// Verify that all templates have all required parts.
Map<String, CharSequence> base = new HashMap<>();
Map<String, CharSequence> all = new HashMap<>();
for (Map.Entry<String, StatusPart> me : mParts.entrySet()) {
if (me.getValue() instanceof TextStatusPart
|| me.getValue() instanceof TimerStatusPart) {
base.put(me.getKey(), "");
}
all.put(me.getKey(), "");
}
if (processTemplate(templates.get(templates.size() - 1), base) == null) {
throw new IllegalStateException("For backwards compatibility reasons the last "
+ "templateThe should only use TextStatusPart & TimerStatusPart");
}
for (CharSequence template : templates) {
if (processTemplate(template, all) == null) {
throw new IllegalStateException("The template \"" + template + "\" is missing"
+ " some parts for rendering.");
}
}
return new Status(templates, mParts);
}
}
/**
* @return the list of templates that this status has.
*/
@NonNull
public List<CharSequence> getTemplates() {
return mTemplates;
}
/**
* @return the names of the parts provide to this status.
*/
@NonNull
public Set<String> getPartNames() {
return Collections.unmodifiableSet(mParts.keySet());
}
/**
* Returns the value of the part with the given name.
*
* @param name the name to lookup.
* @return the part with the given name, can be null.
*/
@Nullable
public Part getPart(@NonNull String name) {
return Part.fromVersionedParcelable(mParts.get(name));
}
/**
* Process a template and replace placeholders with the provided values.
* Placeholders are named, delimited by '#'. For example: '#name#'
* To produce a '#' in the output, use '##' in the template.
*
* @param template The template to use as base.
* @param values The values to replace the placeholders in the template with.
* @return The template with the placeholders replaced, or null if the template references a
* value that it's not present (or null).
*/
@Nullable
static CharSequence processTemplate(@NonNull CharSequence template,
@NonNull Map<String, CharSequence> values) {
SpannableStringBuilder ssb = new SpannableStringBuilder(template);
int opening = -1;
for (int i = 0; i < ssb.length(); i++) {
if (ssb.charAt(i) == '#') {
if (opening >= 0) {
// Replace '##' with '#'
// Replace '#varName#' with the value from the map.
CharSequence replaceWith =
opening == i - 1 ? "#" :
values.get(ssb.subSequence(opening + 1, i).toString());
if (replaceWith == null) {
return null;
}
ssb.replace(opening, i + 1, replaceWith);
i = opening + replaceWith.length() - 1;
opening = -1;
} else {
opening = i;
}
}
}
return ssb;
}
/**
* Returns a textual representation of this status at the given time. The first template that
* has all required information will be used, and each part will be used in their respective
* placeholder/s.
*
* @param context may be used for internationalization. Only used while this method
* executed.
* @param timeNowMillis the timestamp of the time we want to display, usually now, as
* @return the rendered text, for best compatibility, display using a TextView.
*/
@NonNull
@Override
public CharSequence getText(@NonNull Context context, long timeNowMillis) {
Map<String, CharSequence> texts = new HashMap<>();
for (Map.Entry<String, StatusPart> me : mParts.entrySet()) {
CharSequence text = me.getValue().getText(context, timeNowMillis);
texts.put(me.getKey(), text);
}
for (CharSequence template : mTemplates) {
CharSequence ret = processTemplate(template, texts);
if (ret != null) {
return ret;
}
}
return "";
}
/**
* Returns the next time this status could have a different rendering.
* There is no guarantee that the rendering will change at the returned time (for example, if
* some information in the status is not rendered).
*
* @param fromTimeMillis current time, usually now as returned by
* {@link android.os.SystemClock#elapsedRealtime()}. In most cases
* {@code getText} and {@code getNextChangeTimeMillis} should be called
* with the exact same timestamp, so changes are not missed.
* @return the next time (counting from fromTimeMillis) that this status may produce a
* different result when calling getText().
*/
@Override
public long getNextChangeTimeMillis(long fromTimeMillis) {
long ret = Long.MAX_VALUE;
for (StatusPart part : mParts.values()) {
ret = Math.min(ret, part.getNextChangeTimeMillis(fromTimeMillis));
}
return ret;
}
}