blob: ea5a2e8e4f9898ec1b080b744f3f952b6b7ad8c4 [file] [log] [blame]
/*
* Copyright (C) 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 com.android.car.hal;
import static android.car.VehiclePropertyIds.INITIAL_USER_INFO;
import static android.car.VehiclePropertyIds.SWITCH_USER;
import static android.car.VehiclePropertyIds.USER_IDENTIFICATION_ASSOCIATION;
import static com.android.internal.util.function.pooled.PooledLambda.obtainMessage;
import android.annotation.NonNull;
import android.annotation.Nullable;
import android.app.ActivityManager;
import android.car.hardware.property.CarPropertyManager;
import android.car.user.CarUserManager;
import android.car.userlib.HalCallback;
import android.car.userlib.UserHalHelper;
import android.hardware.automotive.vehicle.V2_0.InitialUserInfoResponse;
import android.hardware.automotive.vehicle.V2_0.InitialUserInfoResponseAction;
import android.hardware.automotive.vehicle.V2_0.SwitchUserMessageType;
import android.hardware.automotive.vehicle.V2_0.SwitchUserResponse;
import android.hardware.automotive.vehicle.V2_0.SwitchUserStatus;
import android.hardware.automotive.vehicle.V2_0.UserFlags;
import android.hardware.automotive.vehicle.V2_0.UserIdentificationAssociationType;
import android.hardware.automotive.vehicle.V2_0.UserIdentificationGetRequest;
import android.hardware.automotive.vehicle.V2_0.UserIdentificationResponse;
import android.hardware.automotive.vehicle.V2_0.UserInfo;
import android.hardware.automotive.vehicle.V2_0.UsersInfo;
import android.hardware.automotive.vehicle.V2_0.VehiclePropConfig;
import android.hardware.automotive.vehicle.V2_0.VehiclePropValue;
import android.os.Handler;
import android.os.Looper;
import android.os.ServiceSpecificException;
import android.os.UserHandle;
import android.sysprop.CarProperties;
import android.text.TextUtils;
import android.util.EventLog;
import android.util.Log;
import android.util.Pair;
import android.util.Slog;
import android.util.SparseArray;
import android.util.SparseBooleanArray;
import com.android.internal.annotations.GuardedBy;
import com.android.internal.car.EventLogTags;
import com.android.internal.util.Preconditions;
import java.io.PrintWriter;
import java.util.Collection;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
/**
* Service used to integrate the OEM's custom user management with Android's.
*/
public final class UserHalService extends HalServiceBase {
private static final String TAG = UserHalService.class.getSimpleName();
private static final String UNSUPPORTED_MSG = "Vehicle HAL does not support user management";
private static final int[] SUPPORTED_PROPERTIES = new int[]{
INITIAL_USER_INFO,
SWITCH_USER,
USER_IDENTIFICATION_ASSOCIATION
};
// TODO(b/150413515): STOPSHIP - change to false before R is launched
private static final boolean DBG = true;
private final Object mLock = new Object();
private final VehicleHal mHal;
@GuardedBy("mLock")
@Nullable
private SparseArray<VehiclePropConfig> mProperties;
// This handler handles 2 types of messages:
// - "Anonymous" messages (what=0) containing runnables.
// - "Identifiable" messages used to check for timeouts (whose 'what' is the request id).
private final Handler mHandler = new Handler(Looper.getMainLooper());
/**
* Value used on the next request.
*/
@GuardedBy("mLock")
private int mNextRequestId = 1;
/**
* Map of callbacks by request id.
*/
@GuardedBy("mLock")
private SparseArray<Pair<Class<?>, HalCallback<?>>> mPendingCallbacks = new SparseArray<>();
public UserHalService(VehicleHal hal) {
mHal = hal;
}
@Override
public void init() {
if (DBG) Log.d(TAG, "init()");
if (mProperties == null) {
return;
}
int size = mProperties.size();
for (int i = 0; i < size; i++) {
VehiclePropConfig config = mProperties.valueAt(i);
if (VehicleHal.isPropertySubscribable(config)) {
if (DBG) Log.d(TAG, "subscribing to property " + config.prop);
mHal.subscribeProperty(this, config.prop);
}
}
}
@Override
public void release() {
if (DBG) Log.d(TAG, "release()");
}
@Override
public void onHalEvents(List<VehiclePropValue> values) {
if (DBG) Log.d(TAG, "handleHalEvents(): " + values);
for (int i = 0; i < values.size(); i++) {
VehiclePropValue value = values.get(i);
switch (value.prop) {
case INITIAL_USER_INFO:
mHandler.sendMessage(obtainMessage(
UserHalService::handleOnInitialUserInfoResponse, this, value));
break;
case SWITCH_USER:
mHandler.sendMessage(obtainMessage(
UserHalService::handleOnSwitchUserResponse, this, value));
break;
case USER_IDENTIFICATION_ASSOCIATION:
Slog.w(TAG, "HAL updated event for USER_IDENTIFICATION_ASSOCIATION: " + value);
break;
default:
Slog.w(TAG, "received unsupported event from HAL: " + value);
}
}
}
@Override
public void onPropertySetError(int property, int area,
@CarPropertyManager.CarSetPropertyErrorCode int errorCode) {
if (DBG) Log.d(TAG, "handlePropertySetError(" + property + "/" + area + ")");
}
@Override
public int[] getAllSupportedProperties() {
return SUPPORTED_PROPERTIES;
}
@Override
public void takeProperties(Collection<VehiclePropConfig> properties) {
if (properties.isEmpty()) {
Log.w(TAG, UNSUPPORTED_MSG);
return;
}
// TODO(b/150413515): increase capacity as more properties are added
SparseArray<VehiclePropConfig> supportedProperties = new SparseArray<>(2);
for (VehiclePropConfig config : properties) {
supportedProperties.put(config.prop, config);
}
synchronized (mLock) {
mProperties = supportedProperties;
}
}
/**
* Checks if the Vehicle HAL supports user management.
*/
public boolean isSupported() {
synchronized (mLock) {
return mProperties != null;
}
}
@GuardedBy("mLock")
private void checkSupportedLocked() {
Preconditions.checkState(isSupported(), UNSUPPORTED_MSG);
}
/**
* Calls HAL to asynchronously get info about the initial user.
*
* @param requestType type of request (as defined by
* {@link android.hardware.automotive.vehicle.V2_0.InitialUserInfoRequestType}).
* @param timeoutMs how long to wait (in ms) for the property change event.
* @param usersInfo current state of Android users.
* @param callback to handle the response.
*
* @throws IllegalStateException if the HAL does not support user management (callers should
* call {@link #isSupported()} first to avoid this exception).
*/
public void getInitialUserInfo(int requestType, int timeoutMs, @NonNull UsersInfo usersInfo,
@NonNull HalCallback<InitialUserInfoResponse> callback) {
if (DBG) Log.d(TAG, "getInitialInfo(" + requestType + ")");
Preconditions.checkArgumentPositive(timeoutMs, "timeout must be positive");
Objects.requireNonNull(usersInfo);
// TODO(b/150413515): use helper method to check usersInfo is valid
Objects.requireNonNull(callback);
VehiclePropValue propRequest;
int requestId;
synchronized (mLock) {
checkSupportedLocked();
if (hasPendingRequestLocked(InitialUserInfoResponse.class, callback)) return;
requestId = mNextRequestId++;
EventLog.writeEvent(EventLogTags.CAR_USER_HAL_INITIAL_USER_INFO_REQ, requestId,
requestType, timeoutMs);
propRequest = UserHalHelper.createPropRequest(requestId, requestType,
INITIAL_USER_INFO);
UserHalHelper.addUsersInfo(propRequest, usersInfo);
addPendingRequestLocked(requestId, InitialUserInfoResponse.class, callback);
}
mHandler.sendMessageDelayed(obtainMessage(
UserHalService::handleCheckIfRequestTimedOut, this, requestId).setWhat(requestId),
timeoutMs);
try {
if (DBG) Log.d(TAG, "Calling hal.set(): " + propRequest);
mHal.set(propRequest);
} catch (ServiceSpecificException e) {
handleRemovePendingRequest(requestId);
Log.w(TAG, "Failed to set INITIAL_USER_INFO", e);
callback.onResponse(HalCallback.STATUS_HAL_SET_TIMEOUT, null);
}
}
/**
* Calls HAL to asynchronously switch user.
*
* @param targetInfo target user for user switching
* @param timeoutMs how long to wait (in ms) for the property change event.
* @param usersInfo current state of Android users.
* @param callback to handle the response.
*
* @throws IllegalStateException if the HAL does not support user management (callers should
* call {@link #isSupported()} first to avoid this exception).
*/
public void switchUser(@NonNull UserInfo targetInfo, int timeoutMs,
@NonNull UsersInfo usersInfo, @NonNull HalCallback<SwitchUserResponse> callback) {
if (DBG) Log.d(TAG, "switchUser(" + targetInfo + ")");
// TODO(b/150413515): check that targetInfo is not null / add unit test
Preconditions.checkArgumentPositive(timeoutMs, "timeout must be positive");
Objects.requireNonNull(usersInfo);
// TODO(b/150413515): use helper method to check usersInfo is valid
Objects.requireNonNull(callback);
VehiclePropValue propRequest;
int requestId;
synchronized (mLock) {
checkSupportedLocked();
if (hasPendingRequestLocked(SwitchUserResponse.class, callback)) return;
requestId = mNextRequestId++;
EventLog.writeEvent(EventLogTags.CAR_USER_HAL_SWITCH_USER_REQ, requestId,
targetInfo.userId, timeoutMs);
propRequest = getPropRequestForSwitchUserLocked(requestId,
SwitchUserMessageType.ANDROID_SWITCH, targetInfo, usersInfo);
addPendingRequestLocked(requestId, SwitchUserResponse.class, callback);
}
mHandler.sendMessageDelayed(
obtainMessage(UserHalService::handleCheckIfRequestTimedOut, this, requestId)
.setWhat(requestId),
timeoutMs);
try {
if (DBG) Log.d(TAG, "Calling hal.set(): " + propRequest);
mHal.set(propRequest);
} catch (ServiceSpecificException e) {
handleRemovePendingRequest(requestId);
Log.w(TAG, "Failed to set ANDROID SWITCH", e);
callback.onResponse(HalCallback.STATUS_HAL_SET_TIMEOUT, null);
}
}
/**
* Calls HAL after android user switch.
*
* @param requestId for which switch response is sent.
* @param targetInfo target user info.
* @param usersInfo current state of Android users.
*/
public void postSwitchResponse(int requestId, @NonNull UserInfo targetInfo,
@NonNull UsersInfo usersInfo) {
EventLog.writeEvent(EventLogTags.CAR_USER_HAL_POST_SWITCH_USER_REQ, requestId,
targetInfo.userId, usersInfo.currentUser.userId);
if (DBG) Log.d(TAG, "postSwitchResponse(" + targetInfo + ")");
Objects.requireNonNull(usersInfo);
// TODO(b/150413515): use helper method to check usersInfo is valid
VehiclePropValue propRequest;
synchronized (mLock) {
checkSupportedLocked();
propRequest = getPropRequestForSwitchUserLocked(requestId,
SwitchUserMessageType.ANDROID_POST_SWITCH, targetInfo, usersInfo);
}
try {
if (DBG) Log.d(TAG, "Calling hal.set(): " + propRequest);
mHal.set(propRequest);
} catch (ServiceSpecificException e) {
Log.w(TAG, "Failed to set ANDROID POST SWITCH", e);
}
}
/**
* Calls HAL to switch user after legacy Android user switch. Legacy Android user switch means
* user switch is not requested by {@link CarUserManager} or OEM, and user switch is directly
* requested by {@link ActivityManager}
*
* @param targetInfo target user info.
* @param usersInfo current state of Android users.
*/
public void legacyUserSwitch(@NonNull UserInfo targetInfo, @NonNull UsersInfo usersInfo) {
if (DBG) Log.d(TAG, "userSwitchLegacy(" + targetInfo + ")");
Objects.requireNonNull(usersInfo);
// TODO(b/150413515): use helper method to check usersInfo is valid
VehiclePropValue propRequest;
synchronized (mLock) {
checkSupportedLocked();
int requestId = mNextRequestId++;
EventLog.writeEvent(EventLogTags.CAR_USER_HAL_LEGACY_SWITCH_USER_REQ, requestId,
targetInfo.userId, usersInfo.currentUser.userId);
propRequest = getPropRequestForSwitchUserLocked(requestId,
SwitchUserMessageType.LEGACY_ANDROID_SWITCH, targetInfo, usersInfo);
}
try {
if (DBG) Log.d(TAG, "Calling hal.set(): " + propRequest);
mHal.set(propRequest);
} catch (ServiceSpecificException e) {
Log.w(TAG, "Failed to set LEGACY ANDROID SWITCH", e);
}
}
private VehiclePropValue getPropRequestForSwitchUserLocked(int requestId, int requestType,
@NonNull UserInfo targetInfo, @NonNull UsersInfo usersInfo) {
VehiclePropValue propRequest =
UserHalHelper.createPropRequest(requestId, requestType, SWITCH_USER);
propRequest.value.int32Values.add(targetInfo.userId);
propRequest.value.int32Values.add(targetInfo.flags);
UserHalHelper.addUsersInfo(propRequest, usersInfo);
return propRequest;
}
/**
* Calls HAL to get the value of the user identifications associated with the given user.
*
* @return HAL response or {@code null} if it was invalid (for example, mismatch on the
* requested number of associations).
*
* @throws IllegalArgumentException if request is invalid (mismatch on number of associations,
* duplicated association, invalid association type values, etc).
*/
@Nullable
public UserIdentificationResponse getUserAssociation(
@NonNull UserIdentificationGetRequest request) {
Objects.requireNonNull(request, "request cannot be null");
// Check that it doesn't have dupes
SparseBooleanArray types = new SparseBooleanArray(request.numberAssociationTypes);
for (int i = 0; i < request.numberAssociationTypes; i++) {
int type = request.associationTypes.get(i);
Preconditions.checkArgument(!types.get(type), "type %s found more than once on %s",
UserIdentificationAssociationType.toString(type), request);
types.put(type, true);
}
if (DBG) Log.d(TAG, "getUserAssociation(): req=" + request);
VehiclePropValue requestAsPropValue = UserHalHelper.toVehiclePropValue(request);
EventLog.writeEvent(EventLogTags.CAR_USER_HAL_GET_USER_AUTH_REQ,
requestAsPropValue.value.int32Values.toArray());
VehiclePropValue responseAsPropValue = mHal.get(requestAsPropValue);
if (responseAsPropValue == null) {
Log.w(TAG, "HAL returned null for request " + requestAsPropValue);
return null;
}
if (TextUtils.isEmpty(responseAsPropValue.value.stringValue)) {
EventLog.writeEvent(EventLogTags.CAR_USER_HAL_GET_USER_AUTH_RESP,
responseAsPropValue.value.int32Values.toArray());
} else {
// Must manually append the error message to the array of values
int size = responseAsPropValue.value.int32Values.size();
Object[] list = new Object[size + 1];
responseAsPropValue.value.int32Values.toArray(list);
list[list.length - 1] = responseAsPropValue.value.stringValue;
EventLog.writeEvent(EventLogTags.CAR_USER_HAL_GET_USER_AUTH_RESP, list);
}
if (DBG) Log.d(TAG, "getUserAssociation(): responseAsPropValue=" + responseAsPropValue);
UserIdentificationResponse response;
try {
response = UserHalHelper.toUserIdentificationGetResponse(responseAsPropValue);
} catch (IllegalArgumentException e) {
Log.w(TAG, "invalid response from HAL for " + requestAsPropValue, e);
return null;
}
if (DBG) Log.d(TAG, "getUserAssociation(): response=" + response);
// Validate the response according to the request
if (response.numberAssociation != request.numberAssociationTypes) {
Log.w(TAG, "Wrong number of association types on HAL response (expected "
+ request.numberAssociationTypes + ") for request " + requestAsPropValue
+ ": " + response);
return null;
}
for (int i = 0; i < request.numberAssociationTypes; i++) {
int expectedType = request.associationTypes.get(i);
int actualType = response.associations.get(i).type;
if (actualType != expectedType) {
Log.w(TAG, "Wrong type on index " + i + " of HAL response (" + response + ") for "
+ "request " + requestAsPropValue + " : expected "
+ UserIdentificationAssociationType.toString(expectedType)
+ ", got " + UserIdentificationAssociationType.toString(actualType));
return null;
}
}
return response;
}
@GuardedBy("mLock")
private void addPendingRequestLocked(int requestId, @NonNull Class<?> responseClass,
@NonNull HalCallback<?> callback) {
if (DBG) {
Log.d(TAG, "adding pending callback (of type " + responseClass.getName()
+ ") for request " + requestId);
}
mPendingCallbacks.put(requestId, new Pair<>(responseClass, callback));
}
/**
* Checks if there is a pending request of type {@code requestClass}, calling {@code callback}
* with {@link HalCallback#STATUS_CONCURRENT_OPERATION} when there is.
*/
@GuardedBy("mLock")
private boolean hasPendingRequestLocked(@NonNull Class<?> requestClass,
@NonNull HalCallback<?> callback) {
for (int i = 0; i < mPendingCallbacks.size(); i++) {
Pair<Class<?>, HalCallback<?>> pair = mPendingCallbacks.valueAt(i);
if (pair.first == requestClass) {
Log.w(TAG, "Already have pending request of type " + requestClass);
callback.onResponse(HalCallback.STATUS_CONCURRENT_OPERATION, null);
return true;
}
}
return false;
}
/**
* Removes the pending request and its timeout callback.
*/
private void handleRemovePendingRequest(int requestId) {
if (DBG) Log.d(TAG, "Removing pending request #" + requestId);
mHandler.removeMessages(requestId);
synchronized (mLock) {
mPendingCallbacks.remove(requestId);
}
}
private void handleCheckIfRequestTimedOut(int requestId) {
Pair<Class<?>, HalCallback<?>> pair = getPendingCallback(requestId);
if (pair == null) return;
Log.w(TAG, "Request #" + requestId + " timed out");
handleRemovePendingRequest(requestId);
pair.second.onResponse(HalCallback.STATUS_HAL_RESPONSE_TIMEOUT, null);
}
@Nullable
private Pair<Class<?>, HalCallback<?>> getPendingCallback(int requestId) {
synchronized (mLock) {
return mPendingCallbacks.get(requestId);
}
}
private void handleOnInitialUserInfoResponse(VehiclePropValue value) {
// TODO(b/150413515): record (for dumping()) the last N responses.
int requestId = value.value.int32Values.get(0);
HalCallback<InitialUserInfoResponse> callback = handleGetPendingCallback(requestId,
InitialUserInfoResponse.class);
if (callback == null) {
EventLog.writeEvent(EventLogTags.CAR_USER_HAL_INITIAL_USER_INFO_RESP, requestId,
HalCallback.STATUS_INVALID);
Log.w(TAG, "no callback for requestId " + requestId + ": " + value);
return;
}
handleRemovePendingRequest(requestId);
InitialUserInfoResponse response = new InitialUserInfoResponse();
// TODO(b/150413515): use helper method to convert prop value to proper response
response.requestId = requestId;
response.action = value.value.int32Values.get(1);
switch (response.action) {
case InitialUserInfoResponseAction.DEFAULT:
response.userToSwitchOrCreate.userId = UserHandle.USER_NULL;
response.userToSwitchOrCreate.flags = UserFlags.NONE;
break;
case InitialUserInfoResponseAction.SWITCH:
response.userToSwitchOrCreate.userId = value.value.int32Values.get(2);
response.userToSwitchOrCreate.flags = UserFlags.NONE;
break;
case InitialUserInfoResponseAction.CREATE:
response.userToSwitchOrCreate.userId = UserHandle.USER_NULL;
response.userToSwitchOrCreate.flags = value.value.int32Values.get(2);
response.userNameToCreate = value.value.stringValue;
break;
default:
Log.e(TAG, "invalid action (" + response.action + ") from HAL: " + value);
EventLog.writeEvent(EventLogTags.CAR_USER_HAL_INITIAL_USER_INFO_RESP, requestId,
HalCallback.STATUS_WRONG_HAL_RESPONSE);
callback.onResponse(HalCallback.STATUS_WRONG_HAL_RESPONSE, null);
return;
}
EventLog.writeEvent(EventLogTags.CAR_USER_HAL_INITIAL_USER_INFO_RESP, requestId,
HalCallback.STATUS_OK, response.action,
response.userToSwitchOrCreate.userId, response.userToSwitchOrCreate.flags,
response.userNameToCreate);
if (DBG) Log.d(TAG, "replying to request " + requestId + " with " + response);
callback.onResponse(HalCallback.STATUS_OK, response);
}
private void handleOnSwitchUserResponse(VehiclePropValue value) {
int requestId = value.value.int32Values.get(0);
HalCallback<SwitchUserResponse> callback =
handleGetPendingCallback(requestId, SwitchUserResponse.class);
if (callback == null) {
EventLog.writeEvent(EventLogTags.CAR_USER_HAL_SWITCH_USER_RESP, requestId,
HalCallback.STATUS_INVALID);
Log.w(TAG, "no callback for requestId " + requestId + ": " + value);
return;
}
handleRemovePendingRequest(requestId);
SwitchUserResponse response = new SwitchUserResponse();
response.requestId = requestId;
response.messageType = value.value.int32Values.get(1);
if (response.messageType != SwitchUserMessageType.VEHICLE_RESPONSE) {
EventLog.writeEvent(EventLogTags.CAR_USER_HAL_SWITCH_USER_RESP, requestId,
HalCallback.STATUS_WRONG_HAL_RESPONSE);
Log.e(TAG, "invalid message type (" + response.messageType + ") from HAL: " + value);
callback.onResponse(HalCallback.STATUS_WRONG_HAL_RESPONSE, null);
return;
}
response.status = value.value.int32Values.get(2);
if (response.status == SwitchUserStatus.SUCCESS
|| response.status == SwitchUserStatus.FAILURE) {
if (DBG) {
Log.d(TAG, "replying to request " + requestId + " with " + response);
}
EventLog.writeEvent(EventLogTags.CAR_USER_HAL_SWITCH_USER_RESP, requestId,
HalCallback.STATUS_OK, response.status);
callback.onResponse(HalCallback.STATUS_OK, response);
} else {
EventLog.writeEvent(EventLogTags.CAR_USER_HAL_SWITCH_USER_RESP, requestId,
HalCallback.STATUS_WRONG_HAL_RESPONSE, response.status);
Log.e(TAG, "invalid status (" + response.status + ") from HAL: " + value);
callback.onResponse(HalCallback.STATUS_WRONG_HAL_RESPONSE, null);
}
}
private <T> HalCallback<T> handleGetPendingCallback(int requestId, Class<T> clazz) {
Pair<Class<?>, HalCallback<?>> pair = getPendingCallback(requestId);
if (pair == null) return null;
if (pair.first != clazz) {
Slog.e(TAG, "Invalid callback class for request " + requestId + ": expected" + clazz
+ ", but got is " + pair.first);
// TODO(b/150413515): add unit test for this scenario once it supports other properties
return null;
}
@SuppressWarnings("unchecked")
HalCallback<T> callback = (HalCallback<T>) pair.second;
return callback;
}
@Override
public void dump(PrintWriter writer) {
String indent = " ";
writer.printf("*User HAL*\n");
writer.printf("Relevant CarProperties\n");
dumpSystemProperty(writer, indent, "user_hal_enabled", CarProperties.user_hal_enabled());
dumpSystemProperty(writer, indent, "user_hal_timeout", CarProperties.user_hal_timeout());
synchronized (mLock) {
if (!isSupported()) {
writer.println(UNSUPPORTED_MSG);
return;
}
int numberProperties = mProperties.size();
writer.printf("%d supported properties\n", numberProperties);
for (int i = 0; i < numberProperties; i++) {
writer.printf("%s%s\n", indent, mProperties.valueAt(i));
}
writer.printf("next request id: %d\n", mNextRequestId);
if (mPendingCallbacks.size() == 0) {
writer.println("no pending callbacks");
} else {
writer.printf("pending callbacks: %s\n", mPendingCallbacks);
}
}
}
private void dumpSystemProperty(@NonNull PrintWriter writer, @NonNull String indent,
@NonNull String name, Optional<?> prop) {
String value = prop.isPresent() ? prop.get().toString() : "<NOT SET>";
writer.printf("%s%s=%s\n", indent, name, value);
}
}