diff --git a/src/main/java/eu/jonahbauer/android/preference/annotations/Preference.java b/src/main/java/eu/jonahbauer/android/preference/annotations/Preference.java new file mode 100644 index 0000000..77f04f2 --- /dev/null +++ b/src/main/java/eu/jonahbauer/android/preference/annotations/Preference.java @@ -0,0 +1,60 @@ +package eu.jonahbauer.android.preference.annotations; + +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Defines a preference. The preference key is the value of the string resource + *
{@code R.string.${preference_group_prefix}${name}${preference_group_suffix}}+ * with {@code ${preference_group_prefix}} and {@code ${preference_group_suffix}} being the + * {@linkplain PreferenceGroup#prefix() prefix} and {@linkplain PreferenceGroup#suffix() suffix} defined + * in the enclosing {@link PreferenceGroup}-Annotation. + *
{@code public ${type} ${name}()}+ * and a setter + *
{@code public void ${name}(${type} value)}+ * will be generated in the preference group. Additionally, a key accessor + *
{@code public String ${name}()}+ * is generated in the {@code Keys} class of the preference group for each preference (including {@code void}). + * @see Preferences + * @see PreferenceGroup + */ +@Target({}) +@Retention(RetentionPolicy.CLASS) +public @interface Preference { + String NO_DEFAULT_VALUE = "__NO_DEFAULT_VALUE__"; + + /** + * The name of the preference. Must be a valid Java identifier on its own and when combined with + * the preference gropus {@link PreferenceGroup#prefix()} and {@link PreferenceGroup#suffix()}. + */ + String name(); + + /** + * The type of the preference. Must be one of {@code byte.class}, {@code char.class}, {@code short.class}, + * {@code int.class}, {@code long.class}, {@code float.class}, {@code double.class}, {@code boolean.class} + * {@code String.class} or {@code void.class}. + */ + Class> type(); + + /** + * The default value for the preference. If no default value is provided the default value will be + *
{@code public static final class ${name} {...}}+ * and an accessor + *
{@code public static ${name} ${name}()}+ * will be generated in the preferences class. + * @see Preferences + * @see Preference + */ +@Target({}) +@Retention(RetentionPolicy.CLASS) +public @interface PreferenceGroup { + /** + * A prefix that is prepended to the {@linkplain Preference#name() preference name} in order to build the preference key. + */ + String prefix() default ""; + + /** + * A suffix that is appended to the {@linkplain Preference#name() preference name} in order to build the preference key. + */ + String suffix() default ""; + + /** + * The name of the preference group. This is only used as a class name and does not contribute to the preference key. + * Must be a valid Java identifier. + */ + String name(); + + /** + * A list of {@link Preference}s. + */ + Preference[] value(); +} diff --git a/src/main/java/eu/jonahbauer/android/preference/annotations/Preferences.java b/src/main/java/eu/jonahbauer/android/preference/annotations/Preferences.java new file mode 100644 index 0000000..089c9dd --- /dev/null +++ b/src/main/java/eu/jonahbauer/android/preference/annotations/Preferences.java @@ -0,0 +1,117 @@ +package eu.jonahbauer.android.preference.annotations; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Defines a set of preferences. A preference class for easier and type-safe access to {@code SharedPreferences} + * is generated from this annotation. + *
{@code @Preferences(name = "org.example.AppPreferences$Generated", r = R.class, value = { + * @PreferenceGroup(name = "general", prefix = "preferences_general_", suffix = "_key", value = { + * @Preference(type = byte.class, name = "byte_pref"), + * @Preference(type = short.class, name = "short_pref"), + * @Preference(type = char.class, name = "char_pref"), + * @Preference(type = int.class, name = "int_pref"), + * @Preference(type = long.class, name = "long_pref"), + * @Preference(type = float.class, name = "float_pref"), + * @Preference(type = double.class, name = "double_pref"), + * @Preference(type = String.class, name = "string_pref"), + * @Preference(type = void.class, name = "void_pref") + * } + * } + * public final AppPreferences extends AppPreferences$Generated {} + * }+ * String Resources + *
{@code+ * Generated Code + *+ * + * }... + *... + *... + *... + *... + *... + *... + *... + *
{@code + * class AppPreferences$Generated { + * protected AppPreferences$Generated {...} // throws exception + * + * public static void init(SharedPreferences sharedPreferences, Resources resources) {...} + * + * public static general general() {...} + * + * public static final class general { + * private general(Resources resources) {} // private constructor + * + * public Keys keys() {} + * + * public byte bytePref() {} + * public short shortPref() {} + * public char charPref() {} + * public int intPref() {} + * public long longPref() {} + * public float floatPref() {} + * public double doublePref() {} + * public String stringPref() {} + * + * public void bytePref(byte value) {} + * public void shortPref(short value) {} + * public void charPref(char value) {} + * public void intPref(int value) {} + * public void longPref(long value) {} + * public void floatPref(float value) {} + * public void doublePref(double value) {} + * public void stringPref(String value) {} + * + * public final class Keys { + * private Keys() {} // private constructor + * public String bytePref() {} + * public String shortPref() {} + * public String charPref() {} + * public String intPref() {} + * public String longPref() {} + * public String floatPref() {} + * public String doublePref() {} + * public String stringPref() {} + * } + * } + * } + * }+ */ +@Target(ElementType.TYPE) +@Retention(RetentionPolicy.CLASS) +public @interface Preferences { + /** + * The fully qualified class name of the generated preferences class. + */ + String name(); + + /** + * A list of {@link PreferenceGroup}s. + */ + PreferenceGroup[] value(); + + /** + * The type of the app's {@code R} class. + */ + Class> r(); + + /** + * Whether the generated class should be {@code public final} with a {@code private} constructor, + * or package-private non-{@code final} with a {@code protected} constructor. + *
+ * {@code @Preferences(name = "Prefs$Generated", ...)} + * {@code public class Prefs extends Prefs$Generated {}}+ * Either way the generated class will throw an exception on instantiation. + */ + boolean makeFile() default false; +} diff --git a/src/main/java/eu/jonahbauer/android/preference/annotations/processor/PreferenceProcessor.java b/src/main/java/eu/jonahbauer/android/preference/annotations/processor/PreferenceProcessor.java new file mode 100644 index 0000000..a677de9 --- /dev/null +++ b/src/main/java/eu/jonahbauer/android/preference/annotations/processor/PreferenceProcessor.java @@ -0,0 +1,336 @@ +package eu.jonahbauer.android.preference.annotations.processor; + +import eu.jonahbauer.android.preference.annotations.Preference; +import eu.jonahbauer.android.preference.annotations.Preferences; +import eu.jonahbauer.android.preference.annotations.PreferenceGroup; + +import javax.annotation.processing.*; +import javax.lang.model.SourceVersion; +import javax.lang.model.element.Element; +import javax.lang.model.element.TypeElement; +import javax.lang.model.type.MirroredTypeException; +import javax.lang.model.type.TypeKind; +import javax.lang.model.type.TypeMirror; +import java.io.IOException; +import java.io.PrintWriter; +import java.util.Set; +import java.util.function.Function; + +@SupportedAnnotationTypes({ + "eu.jonahbauer.android.preference.annotations.Preferences" +}) +@SupportedSourceVersion(SourceVersion.RELEASE_11) +public final class PreferenceProcessor extends AbstractProcessor { + private static final String PACKAGE_DECLARATION = "package %s ;%n"; + private static final String IMPORT_DECLARATION = "import android.content.SharedPreferences;%n" + + "import android.content.res.Resources;%n" + + "import java.util.Objects;%n" + + "import %s;%n"; + private static final String CLASS_DECLARATION_START = "class %1$s {%n" + + " protected %1$s() {%n" + + " throw new IllegalStateException(\"This class is not supposed to be instantiated.\");%n" + + " }%n"; + private static final String CLASS_DECLARATION_START_FINAL = "public final class %1$s {%n" + + " private %1$s() {%n" + + " throw new IllegalStateException(\"This class is not supposed to be instantiated.\");%n" + + " }%n"; + private static final String CLASS_PREFERENCES_DECLARATION = " private static SharedPreferences sharedPreferences;%n"; + private static final String GROUP_FIELD_DECLARATION = " private static %1$s group$%2$d;%n"; + private static final String CLASS_INITIALIZER_START = " /**%n" + + " * Initialize this preference class to use the given {@link SharedPreferences}.%n" + + " * This function is supposed to be called from the applications {@code onCreate()} method.%n" + + " * @param pSharedPreferences the {@link SharedPreferences} to be used. Not {@code null}.%n" + + " * @param pResources the {@link Resources} from which the preference keys should be loaded. Not {@code null}.%n" + + " * @throws IllegalStateException if this preference class has already been initialized.%n" + + " */%n" + + " public static void init(SharedPreferences pSharedPreferences, Resources pResources) {%n" + + " if (sharedPreferences != null) {%n" + + " throw new IllegalStateException(\"Preferences have already been initialized.\");%n" + + " }%n" + + " Objects.requireNonNull(pSharedPreferences, \"SharedPreferences must not be null.\");%n" + + " Objects.requireNonNull(pResources, \"Resources must not be null.\");%n" + + " sharedPreferences = pSharedPreferences;%n"; + private static final String CLASS_INITIALIZER_FIELD = " group$%2$d = new %1$s(pResources);%n"; + private static final String CLASS_INITIALIZER_END = " }%n"; + private static final String GROUP_ACCESSOR_DECLARATION = " public static %1$s %1$s() {%n" + + " if (sharedPreferences == null) {%n" + + " throw new IllegalStateException(\"Preferences have not yet been initialized.\");%n" + + " }%n" + + " return group$%2$d;%n" + + " }%n"; + private static final String GROUP_CLASS_DECLARATION_START = " public static final class %s {%n"; + private static final String PROPERTY_KEY = " private final String key$%d;%n"; + private static final String GROUP_CLASS_CONSTRUCTOR_START = " private %s(Resources resources) {%n"; + private static final String PROPERTY_KEY_INITIALIZER = " key$%d = resources.getString(R.string.%s);%n"; + private static final String GROUP_CLASS_CONSTRUCTOR_END = " }%n"; + private static final String PROPERTY_DOCUMENTATION = " /**%n" + + " * %s%n" + + " * (default: {@code %s})%n" + + " */%n"; + private static final String PROPERTY_GETTER_START = " public %s %s() {%n"; + private static final String PROPERTY_GETTER_BODY_BOOLEAN = " return sharedPreferences.getBoolean(key$%d, %s);%n"; + private static final String PROPERTY_GETTER_BODY_INTEGER = " return sharedPreferences.getInt(key$%d, %s);%n"; + private static final String PROPERTY_GETTER_BODY_BYTE = " return (byte) sharedPreferences.getInt(key$%d, %s);%n"; + private static final String PROPERTY_GETTER_BODY_SHORT = " return (short) sharedPreferences.getInt(key$%d, %s);%n"; + private static final String PROPERTY_GETTER_BODY_CHAR = " return (char) sharedPreferences.getInt(key$%d, %s);%n"; + private static final String PROPERTY_GETTER_BODY_LONG = " return sharedPreferences.getLong(key$%d, %s);%n"; + private static final String PROPERTY_GETTER_BODY_FLOAT = " return sharedPreferences.getFloat(key$%d, %s);%n"; + private static final String PROPERTY_GETTER_BODY_DOUBLE = " return Double.longBitsToDouble(sharedPreferences.getLong(key$%d, %s));%n"; + private static final String PROPERTY_GETTER_BODY_STRING = " return sharedPreferences.getString(key$%d, %s);%n"; + private static final String PROPERTY_GETTER_END = " }%n"; + private static final String PROPERTY_SETTER_START = " public void %2$s(%1$s value) {%n"; + private static final String PROPERTY_SETTER_BODY_BOOLEAN = " sharedPreferences.edit().putBoolean(key$%d, value).apply();%n"; + private static final String PROPERTY_SETTER_BODY_INTEGER = " sharedPreferences.edit().putInt(key$%d, value).apply();%n"; + private static final String PROPERTY_SETTER_BODY_BYTE = " sharedPreferences.edit().putInt(key$%d, (int) value).apply();%n"; + private static final String PROPERTY_SETTER_BODY_SHORT = " sharedPreferences.edit().putInt(key$%d, (int) value).apply();%n"; + private static final String PROPERTY_SETTER_BODY_CHAR = " sharedPreferences.edit().putInt(key$%d, (int) value).apply();%n"; + private static final String PROPERTY_SETTER_BODY_LONG = " sharedPreferences.edit().putLong(key$%d, value).apply();%n"; + private static final String PROPERTY_SETTER_BODY_FLOAT = " sharedPreferences.edit().putFloat(key$%d, value).apply();%n"; + private static final String PROPERTY_SETTER_BODY_DOUBLE = " sharedPreferences.edit().putLong(key$%d, Double.doubleToRawLongBits(value)).apply();%n"; + private static final String PROPERTY_SETTER_BODY_STRING = " sharedPreferences.edit().putString(key$%d, value).apply();%n"; + private static final String PROPERTY_SETTER_END = " }%n"; + private static final String KEY_CLASS_FIELD_DECLARATION = " private final Keys keys = new Keys();%n"; + private static final String KEY_CLASS_ACCESSOR = " public Keys keys() {%n" + + " return keys;%n" + + " }%n"; + private static final String KEY_CLASS_DECLARATION_START = " public final class Keys {%n" + + " private Keys() {}%n"; + private static final String KEY_CLASS_PROPERTY_ACCESSOR = " public String %s() {%n" + + " return key$%d;%n" + + " }%n"; + private static final String KEY_CLASS_DECLARATION_END = " }%n"; + private static final String GROUP_CLASS_DECLARATION_END = " }%n"; + private static final String CLASS_DECLARATION_END = "}%n"; + + @Override + public boolean process(Set extends TypeElement> annotations, RoundEnvironment roundEnv) { + var clazzes = roundEnv.getElementsAnnotatedWith(Preferences.class); + + try { + for (Element clazz : clazzes) { + var root = clazz.getAnnotation(Preferences.class); + var source = processingEnv.getFiler().createSourceFile(root.name()); + + try (var out = new PrintWriter(source.openWriter())) { + writeRootClass(out, root); + } + } + return true; + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + private void writeRootClass(PrintWriter out, Preferences root) throws IOException { + var groups = root.value(); + var fqcn = root.name(); + if (!StringUtils.isFQCN(root.name())) { + throw new IllegalArgumentException("Illegal preference class name " + root.name() + "."); + } + var pckg = fqcn.lastIndexOf('.') != -1 ? fqcn.substring(0, fqcn.lastIndexOf('.')) : ""; + var cn = fqcn.lastIndexOf('.') != -1 ? fqcn.substring(fqcn.lastIndexOf('.') + 1) : fqcn; + var r = mirror(root, Preferences::r); + + if (!pckg.isEmpty()) out.printf(PACKAGE_DECLARATION, pckg); + out.println(); + out.printf(IMPORT_DECLARATION, r); + out.println(); + if (root.makeFile()) { + out.printf(CLASS_DECLARATION_START_FINAL, cn); + } else { + out.printf(CLASS_DECLARATION_START, cn); + } + + out.printf(CLASS_PREFERENCES_DECLARATION); + for (int i = 0; i < groups.length; i++) { + out.printf(GROUP_FIELD_DECLARATION, groups[i].name(), i); + } + out.println(); + + out.printf(CLASS_INITIALIZER_START); + for (int i = 0; i < groups.length; i++) { + out.printf(CLASS_INITIALIZER_FIELD, groups[i].name(), i); + } + out.printf(CLASS_INITIALIZER_END); + out.println(); + + for (int i = 0; i < groups.length; i++) { + out.printf(GROUP_ACCESSOR_DECLARATION, groups[i].name(), i); + } + out.println(); + + for (PreferenceGroup group : groups) { + if (!StringUtils.isJavaIdentifier(group.name())) { + throw new IllegalArgumentException("Illegal preference group name " + group.name() + "."); + } + if (!group.prefix().isEmpty() && !StringUtils.isJavaIdentifier(group.prefix())) { + throw new IllegalArgumentException("Illegal preference group prefix " + group.prefix() + "."); + } + if (!group.suffix().isEmpty() && !group.suffix().matches("\\p{javaJavaIdentifierPart}*")) { + throw new IllegalArgumentException("Illegal preference group suffix " + group.suffix() + "."); + } + writeGroupClass(out, group); + } + + out.printf(CLASS_DECLARATION_END); + } + + private void writeGroupClass(PrintWriter out, PreferenceGroup group) { + var preferences = group.value(); + + out.printf(GROUP_CLASS_DECLARATION_START, group.name()); + + // Keys + out.printf(KEY_CLASS_FIELD_DECLARATION); + for (int j = 0; j < preferences.length; j++) { + out.printf(PROPERTY_KEY, j); + } + out.println(); + + // Constructor + out.printf(GROUP_CLASS_CONSTRUCTOR_START, group.name()); + for (int i = 0; i < preferences.length; i++) { + if (!StringUtils.isJavaIdentifier(preferences[i].name())) { + throw new IllegalArgumentException("Illegal preference name " + preferences[i].name() + "."); + } + if (!StringUtils.isJavaIdentifier(group.prefix() + preferences[i].name() + group.suffix())) { + throw new IllegalArgumentException("Illegal preference name " + preferences[i].name() + "."); + } + out.printf(PROPERTY_KEY_INITIALIZER, i, group.prefix() + preferences[i].name() + group.suffix()); + } + out.printf(GROUP_CLASS_CONSTRUCTOR_END); + out.println(); + + // Key class accessor + out.printf(KEY_CLASS_ACCESSOR); + out.println(); + + // Getters + for (int j = 0; j < preferences.length; j++) { + writeGetter(out, preferences[j], j); + } + out.println(); + + // Setters + for (int j = 0; j < preferences.length; j++) { + writeSetter(out, preferences[j], j); + } + out.println(); + + // Key class + out.printf(KEY_CLASS_DECLARATION_START); + for (int j = 0; j < preferences.length; j++) { + out.printf(KEY_CLASS_PROPERTY_ACCESSOR, StringUtils.getMethodName(preferences[j].name()), j); + } + out.printf(KEY_CLASS_DECLARATION_END); + out.println(); + + out.printf(GROUP_CLASS_DECLARATION_END); + out.println(); + } + + private static void writeGetter(PrintWriter out, Preference preference, int index) { + var type = mirror(preference, Preference::type); + if (type.getKind() == TypeKind.VOID) return; + + writeDocumentation(out, preference, type); + out.printf(PROPERTY_GETTER_START, type, StringUtils.getMethodName(preference.name())); + switch (type.getKind()) { + case BOOLEAN: + out.printf(PROPERTY_GETTER_BODY_BOOLEAN, index, getDefaultValue(preference, type)); + break; + case BYTE: + out.printf(PROPERTY_GETTER_BODY_BYTE, index, getDefaultValue(preference, type)); + break; + case CHAR: + out.printf(PROPERTY_GETTER_BODY_CHAR, index, getDefaultValue(preference, type)); + break; + case SHORT: + out.printf(PROPERTY_GETTER_BODY_SHORT, index, getDefaultValue(preference, type)); + break; + case INT: + out.printf(PROPERTY_GETTER_BODY_INTEGER, index, getDefaultValue(preference, type)); + break; + case LONG: + out.printf(PROPERTY_GETTER_BODY_LONG, index, getDefaultValue(preference, type)); + break; + case FLOAT: + out.printf(PROPERTY_GETTER_BODY_FLOAT, index, getDefaultValue(preference, type)); + break; + case DOUBLE: + out.printf(PROPERTY_GETTER_BODY_DOUBLE, index, getDefaultValue(preference, type)); + break; + } + if (String.class.getName().equals(type.toString())) { + out.printf(PROPERTY_GETTER_BODY_STRING, index, getDefaultValue(preference, type)); + } + out.printf(PROPERTY_GETTER_END); + } + + private static void writeSetter(PrintWriter out, Preference preference, int index) { + var type = mirror(preference, Preference::type); + if (type.getKind() == TypeKind.VOID) return; + + writeDocumentation(out, preference, type); + out.printf(PROPERTY_SETTER_START, type, StringUtils.getMethodName(preference.name())); + switch (type.getKind()) { + case BOOLEAN: + out.printf(PROPERTY_SETTER_BODY_BOOLEAN, index); + break; + case BYTE: + out.printf(PROPERTY_SETTER_BODY_BYTE, index); + break; + case SHORT: + out.printf(PROPERTY_SETTER_BODY_SHORT, index); + break; + case CHAR: + out.printf(PROPERTY_SETTER_BODY_CHAR, index); + break; + case INT: + out.printf(PROPERTY_SETTER_BODY_INTEGER, index); + break; + case LONG: + out.printf(PROPERTY_SETTER_BODY_LONG, index); + break; + case FLOAT: + out.printf(PROPERTY_SETTER_BODY_FLOAT, index); + break; + case DOUBLE: + out.printf(PROPERTY_SETTER_BODY_DOUBLE, index); + break; + } + if (String.class.getName().equals(type.toString())) { + out.printf(PROPERTY_SETTER_BODY_STRING, index); + } + out.printf(PROPERTY_SETTER_END); + } + + private static void writeDocumentation(PrintWriter out, Preference preference, TypeMirror type) { + if (preference.description().isEmpty()) return; + out.printf(PROPERTY_DOCUMENTATION, preference.description(), getDefaultValue(preference, type)); + } + + private static String getDefaultValue(Preference preference, TypeMirror type) { + if (!Preference.NO_DEFAULT_VALUE.equals(preference.defaultValue())) { + if (String.class.getName().equals(type.toString())) { + return "\"" + StringUtils.escape(preference.defaultValue()) + "\""; + } else { + return preference.defaultValue(); + } + } else switch (type.getKind()) { + case BOOLEAN: return "false"; + case BYTE: case CHAR: case SHORT: case INT: case LONG: case FLOAT: case DOUBLE: return "0"; + case ARRAY: case NULL: case DECLARED: return "null"; + default: throw new RuntimeException(); + } + } + + private static