LogJson.java

/*
 * Copyright (C) 2023 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.constraintlayout.helper.widget;

import static android.view.ViewGroup.LayoutParams.MATCH_PARENT;
import static android.view.ViewGroup.LayoutParams.WRAP_CONTENT;

import static androidx.constraintlayout.widget.ConstraintSet.Layout.UNSET_GONE_MARGIN;

import android.annotation.SuppressLint;
import android.content.Context;
import android.content.res.TypedArray;
import android.os.Environment;
import android.util.AttributeSet;
import android.util.Log;
import android.util.TypedValue;
import android.view.View;
import android.view.ViewGroup;
import android.widget.Button;
import android.widget.TextView;

import androidx.annotation.RequiresApi;
import androidx.constraintlayout.motion.widget.Debug;
import androidx.constraintlayout.widget.ConstraintAttribute;
import androidx.constraintlayout.widget.ConstraintHelper;
import androidx.constraintlayout.widget.ConstraintLayout;
import androidx.constraintlayout.widget.ConstraintSet;
import androidx.constraintlayout.widget.R;

import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.StringWriter;
import java.io.Writer;
import java.util.HashMap;
import java.util.concurrent.atomic.AtomicInteger;

/**
 * This is a class is a debugging/logging utility to write out the constraints in JSON
 * This is used for debugging purposes
 * <ul>
 *     <li>logJsonTo - defines the output log console or "fileName"</li>
 *     <li>logJsonMode - mode one of:
 *     <b>periodic</b>, <b>delayed</b>, <b>layout</b> or <b>api</b></li>
 *     <li>logJsonDelay - the duration of the delay or the delay between repeated logs</li>
 * </ul>
 * logJsonTo supports:
 * <ul>
 *     <li>log - logs using log.v("JSON5", ...)</li>
 *     <li>console - logs using System.out.println(...)</li>
 *     <li>[fileName] - will write to /storage/emulated/0/Download/[fileName].json5</li>
 * </ul>
 * logJsonMode modes are:
 * <ul>
 *     <li>periodic - after window is attached will log every delay ms</li>
 *     <li>delayed - log once after delay ms</li>
 *     <li>layout - log every time there is a layout call</li>
 *     <li>api - do not automatically log developer will call writeLog</li>
 * </ul>
 *
 * The defaults are:
 * <ul>
 *     <li>logJsonTo="log"</li>
 *     <li>logJsonMode="delayed"</li>
 *     <li>logJsonDelay="1000"</li>
 * </ul>
 *  Usage:
 *  <p></p>
 *  <pre>
 *  {@code
 *      <androidx.constraintlayout.helper.widget.LogJson
 *         android:layout_width="0dp"
 *         android:layout_height="0dp"
 *         android:visibility="gone"
 *         app:logJsonTo="log"
 *         app:logJsonMode="delayed"
 *         app:logJsonDelay="1000"
 *         />
 *  }
 * </pre>
 * </p>
 */
public class LogJson extends ConstraintHelper {
    private static final String TAG = "JSON5";
    private int mDelay = 1000;
    private int mMode = LOG_DELAYED;
    private String mLogToFile = null;
    private boolean mLogConsole = true;

    public static final int LOG_PERIODIC = 1;
    public static final int LOG_DELAYED = 2;
    public static final int LOG_LAYOUT = 3;
    public static final int LOG_API = 4;
    private boolean mPeriodic = false;

    public LogJson(@androidx.annotation.NonNull Context context) {
        super(context);
    }

    public LogJson(@androidx.annotation.NonNull Context context,
                   @androidx.annotation.Nullable AttributeSet attrs) {
        super(context, attrs);
        initLogJson(attrs);

    }

    public LogJson(@androidx.annotation.NonNull Context context,
                   @androidx.annotation.Nullable AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        initLogJson(attrs);
    }

    private void initLogJson(AttributeSet attrs) {

        if (attrs != null) {
            TypedArray a = getContext().obtainStyledAttributes(attrs,
                    R.styleable.LogJson);
            final int count = a.getIndexCount();
            for (int i = 0; i < count; i++) {
                int attr = a.getIndex(i);
                if (attr == R.styleable.LogJson_logJsonDelay) {
                    mDelay = a.getInt(attr, mDelay);
                } else if (attr == R.styleable.LogJson_logJsonMode) {
                    mMode = a.getInt(attr, mMode);
                } else if (attr == R.styleable.LogJson_logJsonTo) {
                    TypedValue v = a.peekValue(attr);
                    if (v.type == TypedValue.TYPE_STRING) {
                        mLogToFile = a.getString(attr);
                    } else {
                        int value = a.getInt(attr, 0);
                        mLogConsole = value == 2;
                    }
                }
            }
            a.recycle();
        }
        setVisibility(GONE);
    }

    @Override
    protected void onAttachedToWindow() {
        super.onAttachedToWindow();
        switch (mMode) {
            case LOG_PERIODIC:
                mPeriodic = true;
                this.postDelayed(this::periodic, mDelay);
                break;
            case LOG_DELAYED:
                this.postDelayed(this::writeLog, mDelay);
                break;
            case LOG_LAYOUT:
                ConstraintLayout cl = (ConstraintLayout) getParent();
                cl.addOnLayoutChangeListener((v, a, b, c, d, e, f, g, h) -> logOnLayout());
        }
    }

    private void logOnLayout() {
        if (mMode == LOG_LAYOUT) {
            writeLog();
        }
    }

    /**
     * Set the duration of periodic logging of constraints
     *
     * @param duration the time in ms between writing files
     */
    public void setDelay(int duration) {
        mDelay = duration;
    }

    /**
     * Start periodic sampling
     */
    public void periodicStart() {
        if (mPeriodic) {
            return;
        }
        mPeriodic = true;
        this.postDelayed(this::periodic, mDelay);
    }

    /**
     * Stop periodic sampling
     */
    public void periodicStop() {
        mPeriodic = false;
    }

    private void periodic() {
        if (mPeriodic) {
            writeLog();
            this.postDelayed(this::periodic, mDelay);
        }
    }

    /**
     * This writes a JSON5 representation of the constraintSet
     */
    public void writeLog() {
        String str = asString((ConstraintLayout) this.getParent());
        if (mLogToFile == null) {
            if (mLogConsole) {
                System.out.println(str);
            } else {
                logBigString(str);
            }
        } else {
            String name = toFile(str, mLogToFile);
            Log.v("JSON", "\"" + name + "\" written!");
        }
    }

    /**
     * This writes the JSON5 description of the constraintLayout to a file named fileName.json5
     * in the download directory which can be pulled with:
     * "adb pull "/storage/emulated/0/Download/" ."
     *
     * @param str      String to write as a file
     * @param fileName file name
     * @return full path name of file
     */
    private static String toFile(String str, String fileName) {
        FileOutputStream outputStream;
        if (!fileName.endsWith(".json5")) {
            fileName += ".json5";
        }
        try {
            File down =
                    Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS);
            File file = new File(down, fileName);
            outputStream = new FileOutputStream(file);
            outputStream.write(str.getBytes());
            outputStream.close();
            return file.getCanonicalPath();
        } catch (IOException e) {
            return e.toString();
        }
    }

    @SuppressLint("LogConditional")
    private void logBigString(String str) {
        int len = str.length();
        for (int i = 0; i < len; i++) {
            int k = str.indexOf("\n", i);
            if (k == -1) {
                Log.v(TAG, str.substring(i));
                break;
            }
            Log.v(TAG, str.substring(i, k));
            i = k;
        }
    }

    /**
     * Get a JSON5 String that represents the Constraints in a running ConstraintLayout
     *
     * @param constraintLayout its constraints are converted to a string
     * @return JSON5 string
     */
    private static String asString(ConstraintLayout constraintLayout) {
        JsonWriter c = new JsonWriter();
        return c.constraintLayoutToJson(constraintLayout);
    }

    // ================================== JSON writer==============================================


    private static class JsonWriter {
        public static final int UNSET = ConstraintLayout.LayoutParams.UNSET;
        ConstraintSet mSet;
        Writer mWriter;
        Context mContext;
        int mUnknownCount = 0;
        final String mLEFT = "left";
        final String mRIGHT = "right";
        final String mBASELINE = "baseline";
        final String mBOTTOM = "bottom";
        final String mTOP = "top";
        final String mSTART = "start";
        final String mEND = "end";
        private static final String INDENT = "    ";
        private static final String SMALL_INDENT = "  ";
        HashMap<Integer, String> mIdMap = new HashMap<>();
        private static final String LOG_JSON = LogJson.class.getSimpleName();
        private static final AtomicInteger sNextGeneratedId = new AtomicInteger(1);
        HashMap<Integer, String> mNames = new HashMap<>();

        private static int generateViewId() {
            final int max_id = 0x00FFFFFF;
            for (;;) {
                final int result = sNextGeneratedId.get();
                int newValue = result + 1;
                if (newValue > max_id) {
                    newValue = 1;
                }
                if (sNextGeneratedId.compareAndSet(result, newValue)) {
                    return result;
                }
            }
        }

        @RequiresApi(17)
        private static class JellyBean {
            static int generateViewId() {
                return View.generateViewId();
            }
        }

        String constraintLayoutToJson(ConstraintLayout constraintLayout) {
            StringWriter writer = new StringWriter();

            int count = constraintLayout.getChildCount();
            for (int i = 0; i < count; i++) {
                View v = constraintLayout.getChildAt(i);
                String name = v.getClass().getSimpleName();
                int id = v.getId();
                if (id == -1) {
                    if (android.os.Build.VERSION.SDK_INT
                            >= android.os.Build.VERSION_CODES.JELLY_BEAN_MR1) {
                        id = JellyBean.generateViewId();
                    } else {
                        id = generateViewId();
                    }
                    v.setId(id);
                    if (!LOG_JSON.equals(name)) {
                        name = "noid_" + name;
                    }
                    mNames.put(id, name);
                } else if (LOG_JSON.equals(name)) {
                    mNames.put(id, name);
                }
            }
            writer.append("{\n");

            writeWidgets(writer, constraintLayout);
            writer.append("  ConstraintSet:{\n");
            ConstraintSet set = new ConstraintSet();
            set.clone(constraintLayout);
            String name =
                    (constraintLayout.getId() == -1) ? "cset" : Debug.getName(constraintLayout);
            try {
                writer.append(name + ":");
                setup(writer, set, constraintLayout);
                writeLayout();
                writer.append("\n");
            } catch (IOException e) {
                throw new RuntimeException(e);
            }
            writer.append("  }\n");
            writer.append("}\n");
            return writer.toString();
        }

        private void writeWidgets(StringWriter writer, ConstraintLayout constraintLayout) {
            writer.append("Widgets:{\n");
            int count = constraintLayout.getChildCount();

            for (int i = -1; i < count; i++) {
                View v = (i == -1) ? constraintLayout : constraintLayout.getChildAt(i);
                int id = v.getId();
                if (LOG_JSON.equals(v.getClass().getSimpleName())) {
                    continue;
                }
                String name = mNames.containsKey(id) ? mNames.get(id)
                        : ((i == -1) ? "parent" : Debug.getName(v));
                String cname = v.getClass().getSimpleName();
                String bounds = ", bounds: [" + v.getLeft() + ", " + v.getTop()
                        + ", " + v.getRight() + ", " + v.getBottom() + "]},\n";
                writer.append("  " + name + ": { ");
                if (i == -1) {
                    writer.append("type: '" + v.getClass().getSimpleName() + "' , ");

                    try {
                        ViewGroup.LayoutParams p = (ViewGroup.LayoutParams) v.getLayoutParams();
                        String wrap = "'WRAP_CONTENT'";
                        String match = "'MATCH_PARENT'";
                        String w = p.width == MATCH_PARENT ? match :
                                (p.width == WRAP_CONTENT) ? wrap : p.width + "";
                        writer.append("width: " + w + ", ");
                        String h = p.height == MATCH_PARENT ? match :
                                (p.height == WRAP_CONTENT) ? wrap : p.height + "";
                        writer.append("height: ").append(h);
                    } catch (Exception e) {
                    }
                } else if (cname.contains("Text")) {
                    if (v instanceof TextView) {
                        writer.append("type: 'Text', label: '"
                                + escape(((TextView) v).getText().toString()) + "'");
                    } else {
                        writer.append("type: 'Text' },\n");
                    }
                } else if (cname.contains("Button")) {
                    if (v instanceof Button) {
                        writer.append("type: 'Button', label: '" + ((Button) v).getText() + "'");
                    } else
                        writer.append("type: 'Button'");
                } else if (cname.contains("Image")) {
                    writer.append("type: 'Image'");
                } else if (cname.contains("View")) {
                    writer.append("type: 'Box'");
                } else {
                    writer.append("type: '" + v.getClass().getSimpleName() + "'");
                }
                writer.append(bounds);
            }
            writer.append("},\n");
        }

        private static String escape(String str) {
            return str.replaceAll("'", "\'");
        }

        JsonWriter() {
        }

        void setup(Writer writer,
                   ConstraintSet set,
                   ConstraintLayout layout) throws IOException {
            this.mWriter = writer;
            this.mContext = layout.getContext();
            this.mSet = set;
            set.getConstraint(2);
        }

        private int[] getIDs() {
            return mSet.getKnownIds();
        }

        private ConstraintSet.Constraint getConstraint(int id) {
            return mSet.getConstraint(id);
        }

        private void writeLayout() throws IOException {
            mWriter.write("{\n");
            for (Integer id : getIDs()) {
                ConstraintSet.Constraint c = getConstraint(id);
                String idName = getSimpleName(id);
                if (LOG_JSON.equals(idName)) { // skip LogJson it is for used to log

                    continue;
                }
                mWriter.write(SMALL_INDENT + idName + ":{\n");
                ConstraintSet.Layout l = c.layout;
                if (l.mReferenceIds != null) {
                    StringBuilder ref =
                            new StringBuilder("type: '_" + idName + "_' , contains: [");
                    for (int r = 0; r < l.mReferenceIds.length; r++) {
                        int rid = l.mReferenceIds[r];
                        ref.append((r == 0) ? "" : ", ").append(getName(rid));
                    }
                    mWriter.write(ref + "]\n");
                }
                if (l.mReferenceIdString != null) {
                    StringBuilder ref =
                            new StringBuilder(SMALL_INDENT + "type: '???' , contains: [");
                    String[] rids = l.mReferenceIdString.split(",");
                    for (int r = 0; r < rids.length; r++) {
                        String rid = rids[r];
                        ref.append((r == 0) ? "" : ", ").append("`").append(rid).append("`");
                    }
                    mWriter.write(ref + "]\n");
                }
                writeDimension("height", l.mHeight, l.heightDefault, l.heightPercent,
                        l.heightMin, l.heightMax, l.constrainedHeight);
                writeDimension("width", l.mWidth, l.widthDefault, l.widthPercent,
                        l.widthMin, l.widthMax, l.constrainedWidth);

                writeConstraint(mLEFT, l.leftToLeft, mLEFT, l.leftMargin, l.goneLeftMargin);
                writeConstraint(mLEFT, l.leftToRight, mRIGHT, l.leftMargin, l.goneLeftMargin);
                writeConstraint(mRIGHT, l.rightToLeft, mLEFT, l.rightMargin, l.goneRightMargin);
                writeConstraint(mRIGHT, l.rightToRight, mRIGHT, l.rightMargin, l.goneRightMargin);
                writeConstraint(mBASELINE, l.baselineToBaseline, mBASELINE, UNSET,
                        l.goneBaselineMargin);
                writeConstraint(mBASELINE, l.baselineToTop, mTOP, UNSET, l.goneBaselineMargin);
                writeConstraint(mBASELINE, l.baselineToBottom,
                        mBOTTOM, UNSET, l.goneBaselineMargin);

                writeConstraint(mTOP, l.topToBottom, mBOTTOM, l.topMargin, l.goneTopMargin);
                writeConstraint(mTOP, l.topToTop, mTOP, l.topMargin, l.goneTopMargin);
                writeConstraint(mBOTTOM, l.bottomToBottom, mBOTTOM, l.bottomMargin,
                        l.goneBottomMargin);
                writeConstraint(mBOTTOM, l.bottomToTop, mTOP, l.bottomMargin, l.goneBottomMargin);
                writeConstraint(mSTART, l.startToStart, mSTART, l.startMargin, l.goneStartMargin);
                writeConstraint(mSTART, l.startToEnd, mEND, l.startMargin, l.goneStartMargin);
                writeConstraint(mEND, l.endToStart, mSTART, l.endMargin, l.goneEndMargin);
                writeConstraint(mEND, l.endToEnd, mEND, l.endMargin, l.goneEndMargin);

                writeVariable("horizontalBias", l.horizontalBias, 0.5f);
                writeVariable("verticalBias", l.verticalBias, 0.5f);

                writeCircle(l.circleConstraint, l.circleAngle, l.circleRadius);

                writeGuideline(l.orientation, l.guideBegin, l.guideEnd, l.guidePercent);
                writeVariable("dimensionRatio", l.dimensionRatio);
                writeVariable("barrierMargin", l.mBarrierMargin);
                writeVariable("type", l.mHelperType);
                writeVariable("ReferenceId", l.mReferenceIdString);
                writeVariable("mBarrierAllowsGoneWidgets",
                        l.mBarrierAllowsGoneWidgets, true);
                writeVariable("WrapBehavior", l.mWrapBehavior);

                writeVariable("verticalWeight", l.verticalWeight);
                writeVariable("horizontalWeight", l.horizontalWeight);
                writeVariable("horizontalChainStyle", l.horizontalChainStyle);
                writeVariable("verticalChainStyle", l.verticalChainStyle);
                writeVariable("barrierDirection", l.mBarrierDirection);
                if (l.mReferenceIds != null) {
                    writeVariable("ReferenceIds", l.mReferenceIds);
                }
                writeTransform(c.transform);
                writeCustom(c.mCustomConstraints);

                mWriter.write("  },\n");
            }
            mWriter.write("},\n");
        }

        private void writeTransform(ConstraintSet.Transform transform) throws IOException {
            if (transform.applyElevation) {
                writeVariable("elevation", transform.elevation);
            }
            writeVariable("rotationX", transform.rotationX, 0);
            writeVariable("rotationY", transform.rotationY, 0);
            writeVariable("rotationZ", transform.rotation, 0);
            writeVariable("scaleX", transform.scaleX, 1);
            writeVariable("scaleY", transform.scaleY, 1);
            writeVariable("translationX", transform.translationX, 0);
            writeVariable("translationY", transform.translationY, 0);
            writeVariable("translationZ", transform.translationZ, 0);
        }

        private void writeCustom(HashMap<String, ConstraintAttribute> cset) throws IOException {
            if (cset != null && cset.size() > 0) {
                mWriter.write(INDENT + "custom: {\n");
                for (String s : cset.keySet()) {
                    ConstraintAttribute attr = cset.get(s);
                    if (attr == null) {
                        continue;
                    }
                    String custom = INDENT + SMALL_INDENT + attr.getName() + ": ";
                    switch (attr.getType()) {
                        case INT_TYPE:
                            custom += attr.getIntegerValue();
                            break;
                        case COLOR_TYPE:
                            custom += colorString(attr.getColorValue());
                            break;
                        case FLOAT_TYPE:
                            custom += attr.getFloatValue();
                            break;
                        case STRING_TYPE:
                            custom += "'" + attr.getStringValue() + "'";
                            break;
                        case DIMENSION_TYPE:
                            custom = custom + attr.getFloatValue();
                            break;
                        case REFERENCE_TYPE:
                        case COLOR_DRAWABLE_TYPE:
                        case BOOLEAN_TYPE:
                            custom = null;
                    }
                    if (custom != null) {
                        mWriter.write(custom + ",\n");
                    }
                }
                mWriter.write(SMALL_INDENT + "   } \n");
            }
        }

        private static String colorString(int v) {
            String str = "00000000" + Integer.toHexString(v);
            return "#" + str.substring(str.length() - 8);
        }

        private void writeGuideline(int orientation,
                                    int guideBegin,
                                    int guideEnd,
                                    float guidePercent) throws IOException {
            writeVariable("orientation", orientation);
            writeVariable("guideBegin", guideBegin);
            writeVariable("guideEnd", guideEnd);
            writeVariable("guidePercent", guidePercent);
        }

        private void writeDimension(String dimString,
                                    int dim,
                                    int dimDefault,
                                    float dimPercent,
                                    int dimMin,
                                    int dimMax,
                                    boolean unusedConstrainedDim) throws IOException {
            if (dim == 0) {
                if (dimMax != UNSET || dimMin != UNSET) {
                    String s = "-----";
                    switch (dimDefault) {
                        case 0: // spread

                            s = INDENT + dimString + ": {value:'spread'";
                            break;
                        case 1: //  wrap

                            s = INDENT + dimString + ": {value:'wrap'";
                            break;
                        case 2: // percent

                            s = INDENT + dimString + ": {value: '" + dimPercent + "%'";
                            break;
                    }
                    if (dimMax != UNSET) {
                        s += ", max: " + dimMax;
                    }
                    if (dimMax != UNSET) {
                        s += ", min: " + dimMin;
                    }
                    s += "},\n";
                    mWriter.write(s);
                    return;
                }

                switch (dimDefault) {
                    case 0: // spread is the default

                        break;
                    case 1: //  wrap

                        mWriter.write(INDENT + dimString + ": '???????????',\n");
                        return;
                    case 2: // percent

                        mWriter.write(INDENT + dimString + ": '" + dimPercent + "%',\n");
                }

            } else if (dim == -2) {
                mWriter.write(INDENT + dimString + ": 'wrap',\n");
            } else if (dim == -1) {
                mWriter.write(INDENT + dimString + ": 'parent',\n");
            } else {
                mWriter.write(INDENT + dimString + ": " + dim + ",\n");
            }
        }

        private String getSimpleName(int id) {
            if (mIdMap.containsKey(id)) {
                return "" + mIdMap.get(id);
            }
            if (id == 0) {
                return "parent";
            }
            String name = lookup(id);
            mIdMap.put(id, name);
            return "" + name + "";
        }

        private String getName(int id) {
            return "'" + getSimpleName(id) + "'";
        }

        private String lookup(int id) {
            try {
                if (mNames.containsKey(id)) {
                    return mNames.get(id);
                }
                if (id != -1) {
                    return mContext.getResources().getResourceEntryName(id);
                } else {
                    return "unknown" + ++mUnknownCount;
                }
            } catch (Exception ex) {
                return "unknown" + ++mUnknownCount;
            }
        }

        private void writeConstraint(String my,
                                     int constraint,
                                     String other,
                                     int margin,
                                     int goneMargin) throws IOException {
            if (constraint == UNSET) {
                return;
            }
            mWriter.write(INDENT + my);
            mWriter.write(":[");
            mWriter.write(getName(constraint));
            mWriter.write(", ");
            mWriter.write("'" + other + "'");
            if (margin != 0 || goneMargin != UNSET_GONE_MARGIN) {
                mWriter.write(", " + margin);
                if (goneMargin != UNSET_GONE_MARGIN) {
                    mWriter.write(", " + goneMargin);
                }
            }
            mWriter.write("],\n");
        }

        private void writeCircle(int circleConstraint,
                                 float circleAngle,
                                 int circleRadius) throws IOException {
            if (circleConstraint == UNSET) {
                return;
            }
            mWriter.write(INDENT + "circle");
            mWriter.write(":[");
            mWriter.write(getName(circleConstraint));
            mWriter.write(", " + circleAngle);
            mWriter.write(circleRadius + "],\n");
        }

        private void writeVariable(String name, int value) throws IOException {
            if (value == 0 || value == -1) {
                return;
            }
            mWriter.write(INDENT + name);
            mWriter.write(": " + value);
            mWriter.write(",\n");
        }

        private void writeVariable(String name, float value) throws IOException {
            if (value == UNSET) {
                return;
            }
            mWriter.write(INDENT + name);
            mWriter.write(": " + value);
            mWriter.write(",\n");
        }

        private void writeVariable(String name, float value, float def) throws IOException {
            if (value == def) {
                return;
            }
            mWriter.write(INDENT + name);
            mWriter.write(": " + value);
            mWriter.write(",\n");
        }

        private void writeVariable(String name, boolean value, boolean def) throws IOException {
            if (value == def) {
                return;
            }
            mWriter.write(INDENT + name);
            mWriter.write(": " + value);
            mWriter.write(",\n");
        }

        private void writeVariable(String name, int[] value) throws IOException {
            if (value == null) {
                return;
            }
            mWriter.write(INDENT + name);
            mWriter.write(": ");
            for (int i = 0; i < value.length; i++) {
                mWriter.write(((i == 0) ? "[" : ", ") + getName(value[i]));
            }
            mWriter.write("],\n");
        }

        private void writeVariable(String name, String value) throws IOException {
            if (value == null) {
                return;
            }
            mWriter.write(INDENT + name);
            mWriter.write(": '" + value);
            mWriter.write("',\n");
        }
    }
}