Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit ca046e89 authored by Chalard Jean's avatar Chalard Jean Committed by Gerrit Code Review
Browse files

Merge changes I2ddfef0c,I49bee0c9

* changes:
  [MS08] Read back attributes and blobs.
  [MS07] Implement storeNetworkAttributes and storeBlob.
parents 7bf90a1f bf73e66d
Loading
Loading
Loading
Loading
+13 −2
Original line number Diff line number Diff line
@@ -18,6 +18,8 @@ package android.net.ipmemorystore;

import android.annotation.NonNull;

import com.android.internal.annotations.VisibleForTesting;

/**
 * A parcelable status representing the result of an operation.
 * Parcels as StatusParceled.
@@ -26,7 +28,10 @@ import android.annotation.NonNull;
public class Status {
    public static final int SUCCESS = 0;

    public static final int ERROR_DATABASE_CANNOT_BE_OPENED = -1;
    public static final int ERROR_GENERIC = -1;
    public static final int ERROR_ILLEGAL_ARGUMENT = -2;
    public static final int ERROR_DATABASE_CANNOT_BE_OPENED = -3;
    public static final int ERROR_STORAGE = -4;

    public final int resultCode;

@@ -34,7 +39,8 @@ public class Status {
        this.resultCode = resultCode;
    }

    Status(@NonNull final StatusParcelable parcelable) {
    @VisibleForTesting
    public Status(@NonNull final StatusParcelable parcelable) {
        this(parcelable.resultCode);
    }

@@ -55,7 +61,12 @@ public class Status {
    public String toString() {
        switch (resultCode) {
            case SUCCESS: return "SUCCESS";
            case ERROR_GENERIC: return "GENERIC ERROR";
            case ERROR_ILLEGAL_ARGUMENT: return "ILLEGAL ARGUMENT";
            case ERROR_DATABASE_CANNOT_BE_OPENED: return "DATABASE CANNOT BE OPENED";
            // "DB storage error" is not very helpful but SQLite does not provide specific error
            // codes upon store failure. Thus this indicates SQLite returned some error upon store
            case ERROR_STORAGE: return "DATABASE STORAGE ERROR";
            default: return "Unknown value ?!";
        }
    }
+13 −6
Original line number Diff line number Diff line
@@ -17,18 +17,25 @@
package android.net.ipmemorystore;

import android.annotation.NonNull;
import android.annotation.Nullable;

/** {@hide} */
public class Utils {
    /** Pretty print */
    public static String blobToString(final Blob blob) {
        final StringBuilder sb = new StringBuilder("Blob : [");
        if (blob.data.length <= 24) {
            appendByteArray(sb, blob.data, 0, blob.data.length);
    public static String blobToString(@Nullable final Blob blob) {
        return "Blob : " + byteArrayToString(null == blob ? null : blob.data);
    }

    /** Pretty print */
    public static String byteArrayToString(@Nullable final byte[] data) {
        if (null == data) return "null";
        final StringBuilder sb = new StringBuilder("[");
        if (data.length <= 24) {
            appendByteArray(sb, data, 0, data.length);
        } else {
            appendByteArray(sb, blob.data, 0, 16);
            appendByteArray(sb, data, 0, 16);
            sb.append("...");
            appendByteArray(sb, blob.data, blob.data.length - 8, blob.data.length);
            appendByteArray(sb, data, data.length - 8, data.length);
        }
        sb.append("]");
        return sb.toString();
+231 −2
Original line number Diff line number Diff line
@@ -17,9 +17,24 @@
package com.android.server.net.ipmemorystore;

import android.annotation.NonNull;
import android.annotation.Nullable;
import android.content.ContentValues;
import android.content.Context;
import android.database.Cursor;
import android.database.sqlite.SQLiteDatabase;
import android.database.sqlite.SQLiteException;
import android.database.sqlite.SQLiteOpenHelper;
import android.net.NetworkUtils;
import android.net.ipmemorystore.NetworkAttributes;
import android.net.ipmemorystore.Status;
import android.util.Log;

import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.net.InetAddress;
import java.net.UnknownHostException;
import java.util.ArrayList;
import java.util.List;

/**
 * Encapsulating class for using the SQLite database backing the memory store.
@@ -30,6 +45,8 @@ import android.database.sqlite.SQLiteOpenHelper;
 * @hide
 */
public class IpMemoryStoreDatabase {
    private static final String TAG = IpMemoryStoreDatabase.class.getSimpleName();

    /**
     * Contract class for the Network Attributes table.
     */
@@ -57,7 +74,7 @@ public class IpMemoryStoreDatabase {
        public static final String COLTYPE_DNSADDRESSES = "BLOB";

        public static final String COLNAME_MTU = "mtu";
        public static final String COLTYPE_MTU = "INTEGER";
        public static final String COLTYPE_MTU = "INTEGER DEFAULT -1";

        public static final String CREATE_TABLE = "CREATE TABLE IF NOT EXISTS "
                + TABLENAME                 + " ("
@@ -108,7 +125,7 @@ public class IpMemoryStoreDatabase {
    /** The SQLite DB helper */
    public static class DbHelper extends SQLiteOpenHelper {
        // Update this whenever changing the schema.
        private static final int SCHEMA_VERSION = 1;
        private static final int SCHEMA_VERSION = 2;
        private static final String DATABASE_FILENAME = "IpMemoryStore.db";

        public DbHelper(@NonNull final Context context) {
@@ -140,4 +157,216 @@ public class IpMemoryStoreDatabase {
            onCreate(db);
        }
    }

    @NonNull
    private static byte[] encodeAddressList(@NonNull final List<InetAddress> addresses) {
        final ByteArrayOutputStream os = new ByteArrayOutputStream();
        for (final InetAddress address : addresses) {
            final byte[] b = address.getAddress();
            os.write(b.length);
            os.write(b, 0, b.length);
        }
        return os.toByteArray();
    }

    @NonNull
    private static ArrayList<InetAddress> decodeAddressList(@NonNull final byte[] encoded) {
        final ByteArrayInputStream is = new ByteArrayInputStream(encoded);
        final ArrayList<InetAddress> addresses = new ArrayList<>();
        int d = -1;
        while ((d = is.read()) != -1) {
            final byte[] bytes = new byte[d];
            is.read(bytes, 0, d);
            try {
                addresses.add(InetAddress.getByAddress(bytes));
            } catch (UnknownHostException e) { /* Hopefully impossible */ }
        }
        return addresses;
    }

    // Convert a NetworkAttributes object to content values to store them in a table compliant
    // with the contract defined in NetworkAttributesContract.
    @NonNull
    private static ContentValues toContentValues(@NonNull final String key,
            @Nullable final NetworkAttributes attributes, final long expiry) {
        final ContentValues values = new ContentValues();
        values.put(NetworkAttributesContract.COLNAME_L2KEY, key);
        values.put(NetworkAttributesContract.COLNAME_EXPIRYDATE, expiry);
        if (null != attributes) {
            if (null != attributes.assignedV4Address) {
                values.put(NetworkAttributesContract.COLNAME_ASSIGNEDV4ADDRESS,
                        NetworkUtils.inet4AddressToIntHTH(attributes.assignedV4Address));
            }
            if (null != attributes.groupHint) {
                values.put(NetworkAttributesContract.COLNAME_GROUPHINT, attributes.groupHint);
            }
            if (null != attributes.dnsAddresses) {
                values.put(NetworkAttributesContract.COLNAME_DNSADDRESSES,
                        encodeAddressList(attributes.dnsAddresses));
            }
            if (null != attributes.mtu) {
                values.put(NetworkAttributesContract.COLNAME_MTU, attributes.mtu);
            }
        }
        return values;
    }

    // Convert a byte array into content values to store it in a table compliant with the
    // contract defined in PrivateDataContract.
    @NonNull
    private static ContentValues toContentValues(@NonNull final String key,
            @NonNull final String clientId, @NonNull final String name,
            @NonNull final byte[] data) {
        final ContentValues values = new ContentValues();
        values.put(PrivateDataContract.COLNAME_L2KEY, key);
        values.put(PrivateDataContract.COLNAME_CLIENT, clientId);
        values.put(PrivateDataContract.COLNAME_DATANAME, name);
        values.put(PrivateDataContract.COLNAME_DATA, data);
        return values;
    }

    private static final String[] EXPIRY_COLUMN = new String[] {
        NetworkAttributesContract.COLNAME_EXPIRYDATE
    };
    static final int EXPIRY_ERROR = -1; // Legal values for expiry are positive

    static final String SELECT_L2KEY = NetworkAttributesContract.COLNAME_L2KEY + " = ?";

    // Returns the expiry date of the specified row, or one of the error codes above if the
    // row is not found or some other error
    static long getExpiry(@NonNull final SQLiteDatabase db, @NonNull final String key) {
        final Cursor cursor = db.query(NetworkAttributesContract.TABLENAME,
                EXPIRY_COLUMN, // columns
                SELECT_L2KEY, // selection
                new String[] { key }, // selectionArgs
                null, // groupBy
                null, // having
                null // orderBy
        );
        // L2KEY is the primary key ; it should not be possible to get more than one
        // result here. 0 results means the key was not found.
        if (cursor.getCount() != 1) return EXPIRY_ERROR;
        cursor.moveToFirst();
        return cursor.getLong(0); // index in the EXPIRY_COLUMN array
    }

    static final int RELEVANCE_ERROR = -1; // Legal values for relevance are positive

    // Returns the relevance of the specified row, or one of the error codes above if the
    // row is not found or some other error
    static int getRelevance(@NonNull final SQLiteDatabase db, @NonNull final String key) {
        final long expiry = getExpiry(db, key);
        return expiry < 0 ? (int) expiry : RelevanceUtils.computeRelevanceForNow(expiry);
    }

    // If the attributes are null, this will only write the expiry.
    // Returns an int out of Status.{SUCCESS,ERROR_*}
    static int storeNetworkAttributes(@NonNull final SQLiteDatabase db, @NonNull final String key,
            final long expiry, @Nullable final NetworkAttributes attributes) {
        final ContentValues cv = toContentValues(key, attributes, expiry);
        db.beginTransaction();
        try {
            // Unfortunately SQLite does not have any way to do INSERT OR UPDATE. Options are
            // to either insert with on conflict ignore then update (like done here), or to
            // construct a custom SQL INSERT statement with nested select.
            final long resultId = db.insertWithOnConflict(NetworkAttributesContract.TABLENAME,
                    null, cv, SQLiteDatabase.CONFLICT_IGNORE);
            if (resultId < 0) {
                db.update(NetworkAttributesContract.TABLENAME, cv, SELECT_L2KEY, new String[]{key});
            }
            db.setTransactionSuccessful();
            return Status.SUCCESS;
        } catch (SQLiteException e) {
            // No space left on disk or something
            Log.e(TAG, "Could not write to the memory store", e);
        } finally {
            db.endTransaction();
        }
        return Status.ERROR_STORAGE;
    }

    // Returns an int out of Status.{SUCCESS,ERROR_*}
    static int storeBlob(@NonNull final SQLiteDatabase db, @NonNull final String key,
            @NonNull final String clientId, @NonNull final String name,
            @NonNull final byte[] data) {
        final long res = db.insertWithOnConflict(PrivateDataContract.TABLENAME, null,
                toContentValues(key, clientId, name, data), SQLiteDatabase.CONFLICT_REPLACE);
        return (res == -1) ? Status.ERROR_STORAGE : Status.SUCCESS;
    }

    @Nullable
    static NetworkAttributes retrieveNetworkAttributes(@NonNull final SQLiteDatabase db,
            @NonNull final String key) {
        final Cursor cursor = db.query(NetworkAttributesContract.TABLENAME,
                null, // columns, null means everything
                NetworkAttributesContract.COLNAME_L2KEY + " = ?", // selection
                new String[] { key }, // selectionArgs
                null, // groupBy
                null, // having
                null); // orderBy
        // L2KEY is the primary key ; it should not be possible to get more than one
        // result here. 0 results means the key was not found.
        if (cursor.getCount() != 1) return null;
        cursor.moveToFirst();

        // Make sure the data hasn't expired
        final long expiry = cursor.getLong(
                cursor.getColumnIndexOrThrow(NetworkAttributesContract.COLNAME_EXPIRYDATE));
        if (expiry < System.currentTimeMillis()) return null;

        final NetworkAttributes.Builder builder = new NetworkAttributes.Builder();
        final int assignedV4AddressInt = getInt(cursor,
                NetworkAttributesContract.COLNAME_ASSIGNEDV4ADDRESS, 0);
        final String groupHint = getString(cursor, NetworkAttributesContract.COLNAME_GROUPHINT);
        final byte[] dnsAddressesBlob =
                getBlob(cursor, NetworkAttributesContract.COLNAME_DNSADDRESSES);
        final int mtu = getInt(cursor, NetworkAttributesContract.COLNAME_MTU, -1);
        if (0 != assignedV4AddressInt) {
            builder.setAssignedV4Address(NetworkUtils.intToInet4AddressHTH(assignedV4AddressInt));
        }
        builder.setGroupHint(groupHint);
        if (null != dnsAddressesBlob) {
            builder.setDnsAddresses(decodeAddressList(dnsAddressesBlob));
        }
        if (mtu >= 0) {
            builder.setMtu(mtu);
        }
        return builder.build();
    }

    private static final String[] DATA_COLUMN = new String[] {
            PrivateDataContract.COLNAME_DATA
    };
    @Nullable
    static byte[] retrieveBlob(@NonNull final SQLiteDatabase db, @NonNull final String key,
            @NonNull final String clientId, @NonNull final String name) {
        final Cursor cursor = db.query(PrivateDataContract.TABLENAME,
                DATA_COLUMN, // columns
                PrivateDataContract.COLNAME_L2KEY + " = ? AND " // selection
                + PrivateDataContract.COLNAME_CLIENT + " = ? AND "
                + PrivateDataContract.COLNAME_DATANAME + " = ?",
                new String[] { key, clientId, name }, // selectionArgs
                null, // groupBy
                null, // having
                null); // orderBy
        // The query above is querying by (composite) primary key, so it should not be possible to
        // get more than one result here. 0 results means the key was not found.
        if (cursor.getCount() != 1) return null;
        cursor.moveToFirst();
        return cursor.getBlob(0); // index in the DATA_COLUMN array
    }

    // Helper methods
    static String getString(final Cursor cursor, final String columnName) {
        final int columnIndex = cursor.getColumnIndex(columnName);
        return (columnIndex >= 0) ? cursor.getString(columnIndex) : null;
    }
    static byte[] getBlob(final Cursor cursor, final String columnName) {
        final int columnIndex = cursor.getColumnIndex(columnName);
        return (columnIndex >= 0) ? cursor.getBlob(columnIndex) : null;
    }
    static int getInt(final Cursor cursor, final String columnName, final int defaultValue) {
        final int columnIndex = cursor.getColumnIndex(columnName);
        return (columnIndex >= 0) ? cursor.getInt(columnIndex) : defaultValue;
    }
}
+137 −11
Original line number Diff line number Diff line
@@ -16,6 +16,13 @@

package com.android.server.net.ipmemorystore;

import static android.net.ipmemorystore.Status.ERROR_DATABASE_CANNOT_BE_OPENED;
import static android.net.ipmemorystore.Status.ERROR_GENERIC;
import static android.net.ipmemorystore.Status.ERROR_ILLEGAL_ARGUMENT;
import static android.net.ipmemorystore.Status.SUCCESS;

import static com.android.server.net.ipmemorystore.IpMemoryStoreDatabase.EXPIRY_ERROR;

import android.annotation.NonNull;
import android.annotation.Nullable;
import android.content.Context;
@@ -28,7 +35,12 @@ import android.net.ipmemorystore.IOnL2KeyResponseListener;
import android.net.ipmemorystore.IOnNetworkAttributesRetrieved;
import android.net.ipmemorystore.IOnSameNetworkResponseListener;
import android.net.ipmemorystore.IOnStatusListener;
import android.net.ipmemorystore.NetworkAttributes;
import android.net.ipmemorystore.NetworkAttributesParcelable;
import android.net.ipmemorystore.Status;
import android.net.ipmemorystore.StatusParcelable;
import android.net.ipmemorystore.Utils;
import android.os.RemoteException;
import android.util.Log;

import java.util.concurrent.ExecutorService;
@@ -45,6 +57,7 @@ import java.util.concurrent.Executors;
public class IpMemoryStoreService extends IIpMemoryStore.Stub {
    private static final String TAG = IpMemoryStoreService.class.getSimpleName();
    private static final int MAX_CONCURRENT_THREADS = 4;
    private static final boolean DBG = true;

    @NonNull
    final Context mContext;
@@ -114,6 +127,11 @@ public class IpMemoryStoreService extends IIpMemoryStore.Stub {
        if (mDb != null) mDb.close();
    }

    /** Helper function to make a status object */
    private StatusParcelable makeStatus(final int code) {
        return new Status(code).toParcelable();
    }

    /**
     * Store network attributes for a given L2 key.
     *
@@ -128,11 +146,27 @@ public class IpMemoryStoreService extends IIpMemoryStore.Stub {
     * Through the listener, returns the L2 key. This is useful if the L2 key was not specified.
     * If the call failed, the L2 key will be null.
     */
    // Note that while l2Key and attributes are non-null in spirit, they are received from
    // another process. If the remote process decides to ignore everything and send null, this
    // process should still not crash.
    @Override
    public void storeNetworkAttributes(@NonNull final String l2Key,
            @NonNull final NetworkAttributesParcelable attributes,
    public void storeNetworkAttributes(@Nullable final String l2Key,
            @Nullable final NetworkAttributesParcelable attributes,
            @Nullable final IOnStatusListener listener) {
        // TODO : implement this.
        // Because the parcelable is 100% mutable, the thread may not see its members initialized.
        // Therefore either an immutable object is created on this same thread before it's passed
        // to the executor, or there need to be a write barrier here and a read barrier in the
        // remote thread.
        final NetworkAttributes na = null == attributes ? null : new NetworkAttributes(attributes);
        mExecutor.execute(() -> {
            try {
                final int code = storeNetworkAttributesAndBlobSync(l2Key, na,
                        null /* clientId */, null /* name */, null /* data */);
                if (null != listener) listener.onComplete(makeStatus(code));
            } catch (final RemoteException e) {
                // Client at the other end died
            }
        });
    }

    /**
@@ -141,16 +175,63 @@ public class IpMemoryStoreService extends IIpMemoryStore.Stub {
     * @param l2Key The L2 key for this network.
     * @param clientId The ID of the client.
     * @param name The name of this data.
     * @param data The data to store.
     * @param blob The data to store.
     * @param listener The listener that will be invoked to return the answer, or null if the
     *        is not interested in learning about success/failure.
     * Through the listener, returns a status to indicate success or failure.
     */
    @Override
    public void storeBlob(@NonNull final String l2Key, @NonNull final String clientId,
            @NonNull final String name, @NonNull final Blob data,
    public void storeBlob(@Nullable final String l2Key, @Nullable final String clientId,
            @Nullable final String name, @Nullable final Blob blob,
            @Nullable final IOnStatusListener listener) {
        // TODO : implement this.
        final byte[] data = null == blob ? null : blob.data;
        mExecutor.execute(() -> {
            try {
                final int code = storeNetworkAttributesAndBlobSync(l2Key,
                        null /* NetworkAttributes */, clientId, name, data);
                if (null != listener) listener.onComplete(makeStatus(code));
            } catch (final RemoteException e) {
                // Client at the other end died
            }
        });
    }

    /**
     * Helper method for storeNetworkAttributes and storeBlob.
     *
     * Either attributes or none of clientId, name and data may be null. This will write the
     * passed data if non-null, and will write attributes if non-null, but in any case it will
     * bump the relevance up.
     * Returns a success code from Status.
     */
    private int storeNetworkAttributesAndBlobSync(@Nullable final String l2Key,
            @Nullable final NetworkAttributes attributes,
            @Nullable final String clientId,
            @Nullable final String name, @Nullable final byte[] data) {
        if (null == l2Key) return ERROR_ILLEGAL_ARGUMENT;
        if (null == attributes && null == data) return ERROR_ILLEGAL_ARGUMENT;
        if (null != data && (null == clientId || null == name)) return ERROR_ILLEGAL_ARGUMENT;
        if (null == mDb) return ERROR_DATABASE_CANNOT_BE_OPENED;
        try {
            final long oldExpiry = IpMemoryStoreDatabase.getExpiry(mDb, l2Key);
            final long newExpiry = RelevanceUtils.bumpExpiryDate(
                    oldExpiry == EXPIRY_ERROR ? System.currentTimeMillis() : oldExpiry);
            final int errorCode =
                    IpMemoryStoreDatabase.storeNetworkAttributes(mDb, l2Key, newExpiry, attributes);
            // If no blob to store, the client is interested in the result of storing the attributes
            if (null == data) return errorCode;
            // Otherwise it's interested in the result of storing the blob
            return IpMemoryStoreDatabase.storeBlob(mDb, l2Key, clientId, name, data);
        } catch (Exception e) {
            if (DBG) {
                Log.e(TAG, "Exception while storing for key {" + l2Key
                        + "} ; NetworkAttributes {" + (null == attributes ? "null" : attributes)
                        + "} ; clientId {" + (null == clientId ? "null" : clientId)
                        + "} ; name {" + (null == name ? "null" : name)
                        + "} ; data {" + Utils.byteArrayToString(data) + "}", e);
            }
        }
        return ERROR_GENERIC;
    }

    /**
@@ -198,9 +279,32 @@ public class IpMemoryStoreService extends IIpMemoryStore.Stub {
     *         the query.
     */
    @Override
    public void retrieveNetworkAttributes(@NonNull final String l2Key,
            @NonNull final IOnNetworkAttributesRetrieved listener) {
        // TODO : implement this.
    public void retrieveNetworkAttributes(@Nullable final String l2Key,
            @Nullable final IOnNetworkAttributesRetrieved listener) {
        if (null == listener) return;
        mExecutor.execute(() -> {
            try {
                if (null == l2Key) {
                    listener.onL2KeyResponse(makeStatus(ERROR_ILLEGAL_ARGUMENT), l2Key, null);
                    return;
                }
                if (null == mDb) {
                    listener.onL2KeyResponse(makeStatus(ERROR_DATABASE_CANNOT_BE_OPENED), l2Key,
                            null);
                    return;
                }
                try {
                    final NetworkAttributes attributes =
                            IpMemoryStoreDatabase.retrieveNetworkAttributes(mDb, l2Key);
                    listener.onL2KeyResponse(makeStatus(SUCCESS), l2Key,
                            null == attributes ? null : attributes.toParcelable());
                } catch (final Exception e) {
                    listener.onL2KeyResponse(makeStatus(ERROR_GENERIC), l2Key, null);
                }
            } catch (final RemoteException e) {
                // Client at the other end died
            }
        });
    }

    /**
@@ -217,6 +321,28 @@ public class IpMemoryStoreService extends IIpMemoryStore.Stub {
    @Override
    public void retrieveBlob(@NonNull final String l2Key, @NonNull final String clientId,
            @NonNull final String name, @NonNull final IOnBlobRetrievedListener listener) {
        // TODO : implement this.
        if (null == listener) return;
        mExecutor.execute(() -> {
            try {
                if (null == l2Key) {
                    listener.onBlobRetrieved(makeStatus(ERROR_ILLEGAL_ARGUMENT), l2Key, name, null);
                    return;
                }
                if (null == mDb) {
                    listener.onBlobRetrieved(makeStatus(ERROR_DATABASE_CANNOT_BE_OPENED), l2Key,
                            name, null);
                    return;
                }
                try {
                    final Blob b = new Blob();
                    b.data = IpMemoryStoreDatabase.retrieveBlob(mDb, l2Key, clientId, name);
                    listener.onBlobRetrieved(makeStatus(SUCCESS), l2Key, name, b);
                } catch (final Exception e) {
                    listener.onBlobRetrieved(makeStatus(ERROR_GENERIC), l2Key, name, null);
                }
            } catch (final RemoteException e) {
                // Client at the other end died
            }
        });
    }
}
+7 −0
Original line number Diff line number Diff line
@@ -27,6 +27,7 @@ import android.support.test.runner.AndroidJUnit4;
import org.junit.Test;
import org.junit.runner.RunWith;

import java.lang.reflect.Modifier;
import java.net.Inet4Address;
import java.net.InetAddress;
import java.util.Arrays;
@@ -60,6 +61,12 @@ public class ParcelableTests {
        builder.setMtu(null);
        in = builder.build();
        assertEquals(in, new NetworkAttributes(parcelingRoundTrip(in.toParcelable())));

        // Verify that this test does not miss any new field added later.
        // If any field is added to NetworkAttributes it must be tested here for parceling
        // roundtrip.
        assertEquals(4, Arrays.stream(NetworkAttributes.class.getDeclaredFields())
                .filter(f -> !Modifier.isStatic(f.getModifiers())).count());
    }

    @Test
Loading