/*
* 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.os.Bundle;
import android.text.SpannableStringBuilder;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.RestrictTo;
import androidx.annotation.VisibleForTesting;
import androidx.versionedparcelable.CustomVersionedParcelable;
import androidx.versionedparcelable.NonParcelField;
import androidx.versionedparcelable.ParcelField;
import androidx.versionedparcelable.ParcelUtils;
import androidx.versionedparcelable.VersionedParcelize;
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 serialize / deserialize {@link OngoingActivityStatus} into / from a Notification
*
* A status is composed of Parts, and they are joined together with a template.
*
* Note that for backwards compatibility reasons the code rendering this status message may not
* have all of the [StatusPart] 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.
*
* 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. TextStatusPart & TimerStatusPart
*
* 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()}
*/
@VersionedParcelize(isCustom = true)
public class OngoingActivityStatus extends CustomVersionedParcelable
implements TimeDependentText {
@NonNull
@ParcelField(value = 1)
List<CharSequence> mTemplates = new ArrayList<>();
@NonParcelField
@NonNull
private Map<String, StatusPart> mParts = new HashMap<>();
// Used to serialize/deserialize mParts to avoid http://b/132619460
@ParcelField(value = 2)
Bundle mPartsAsBundle;
// Name of the {@link StatusPart} created when using {@link OngoingActivityStatus.forPart()}
private static final String DEFAULT_STATUS_PART_NAME = "defaultStatusPartName";
// Needed By VersionedParcelables.
OngoingActivityStatus() {
}
// Basic constructor used by the Builder
@VisibleForTesting
OngoingActivityStatus(
@Nullable List<CharSequence> templates,
@NonNull Map<String, StatusPart> parts
) {
mTemplates = templates;
mParts = parts;
}
/**
* 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 OngoingActivityStatus} with just one Part.
*/
@NonNull
public static OngoingActivityStatus forPart(@NonNull StatusPart part) {
// Create an OngoingActivityStatus using only this part and the default template.
return new OngoingActivityStatus.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 StatusPart part) {
mParts.put(name, part);
mDefaultTemplate += (mDefaultTemplate.length() > 0 ? " " : "") + "#" + name + "#";
return this;
}
/**
* Build an OngoingActivityStatus with the given parameters.
* @return the built OngoingActivityStatus
*/
@NonNull
public OngoingActivityStatus 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 OngoingActivityStatus(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 StatusPart getPart(@NonNull String name) {
return 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;
}
// Implementation of CustomVersionedParcelable
/**
* See {@link androidx.versionedparcelable.CustomVersionedParcelable.onPreParceling()}
* @hide
*/
@Override
@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
public void onPreParceling(boolean isStream) {
mPartsAsBundle = new Bundle();
for (Map.Entry<String, StatusPart> me : mParts.entrySet()) {
ParcelUtils.putVersionedParcelable(mPartsAsBundle, me.getKey(), me.getValue());
}
}
/**
* See {@link androidx.versionedparcelable.CustomVersionedParcelable.onPostParceling()}
* @hide
*/
@Override
@RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
public void onPostParceling() {
mParts = new HashMap<>();
for (String key : mPartsAsBundle.keySet()) {
StatusPart part = ParcelUtils.getVersionedParcelable(mPartsAsBundle, key);
if (part != null) {
mParts.put(key, part);
}
}
}
}