package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.codegen; import android.os.Build; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.annotation.RequiresApi; import java.io.File; import java.io.IOException; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.time.DayOfWeek; import java.util.ArrayList; import java.util.Calendar; import java.util.Collections; import java.util.Comparator; import java.util.LinkedHashSet; import java.util.List; import java.util.Objects; import java.util.Set; import java.util.regex.Matcher; import java.util.regex.Pattern; import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.FileType; import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.GlobalFITMessage; import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.RecordData; import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.RecordDefinition; import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.RecordHeader; import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.fieldDefinitions.FieldDefinitionGoalSource; import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.fieldDefinitions.FieldDefinitionGoalType; import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.fieldDefinitions.FieldDefinitionHrvStatus; import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.fieldDefinitions.FieldDefinitionLanguage; import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.fieldDefinitions.FieldDefinitionMeasurementSystem; import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.fieldDefinitions.FieldDefinitionSleepStage; import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.fieldDefinitions.FieldDefinitionWeatherAqi; import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.fieldDefinitions.FieldDefinitionWeatherCondition; import nodomain.freeyourgadget.gadgetbridge.util.FileUtils; // This class is only used to generate code, and will not be packaged in the final apk @RequiresApi(api = Build.VERSION_CODES.O) public class FitCodeGen { public static void main(final String[] args) throws Exception { new FitCodeGen().generate(); } public void generate() throws IOException { final File factoryFile = new File("app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/messages/FitRecordDataFactory.java"); final StringBuilder sbFactory = new StringBuilder(); String header = getHeader(factoryFile); if (!header.isEmpty()) { sbFactory.append(header); sbFactory.append("\n"); } sbFactory.append("package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.messages;\n"); sbFactory.append("\n"); sbFactory.append("import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.RecordData;\n"); sbFactory.append("import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.RecordDefinition;\n"); sbFactory.append("import nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.RecordHeader;\n"); sbFactory.append("\n"); sbFactory.append("//\n"); sbFactory.append("// WARNING: This class was auto-generated, please avoid modifying it directly.\n"); sbFactory.append("// See ").append(getClass().getCanonicalName()).append("\n"); sbFactory.append("//\n"); sbFactory.append("public class FitRecordDataFactory {\n"); sbFactory.append(" private FitRecordDataFactory() {\n"); sbFactory.append(" // use create\n"); sbFactory.append(" }\n"); sbFactory.append("\n"); sbFactory.append(" public static RecordData create(final RecordDefinition recordDefinition, final RecordHeader recordHeader) {\n"); sbFactory.append(" switch (recordDefinition.getGlobalFITMessage().getNumber()) {\n"); final ArrayList globalFITMessages = new ArrayList<>(GlobalFITMessage.KNOWN_MESSAGES.values()); Collections.sort(globalFITMessages, Comparator.comparingInt(GlobalFITMessage::getNumber)); for (final GlobalFITMessage value : globalFITMessages) { final String className = "Fit" + capitalize(toCamelCase(value.name())); sbFactory.append(" case ").append(value.getNumber()).append(":\n"); sbFactory.append(" return new ").append(className).append("(recordDefinition, recordHeader);\n"); process(value); } sbFactory.append(" }\n"); sbFactory.append("\n"); sbFactory.append(" return new RecordData(recordDefinition, recordHeader);\n"); sbFactory.append(" }\n"); sbFactory.append("}\n"); FileUtils.copyStringToFile(sbFactory.toString(), factoryFile, "replace"); } public void process(final GlobalFITMessage globalFITMessage) throws IOException { final String className = "Fit" + capitalize(toCamelCase(globalFITMessage.name())); final File outputFile = new File("app/src/main/java/nodomain/freeyourgadget/gadgetbridge/service/devices/garmin/fit/messages/" + className + ".java"); final List imports = new ArrayList<>(); imports.add(Nullable.class.getCanonicalName()); imports.add(RecordData.class.getCanonicalName()); imports.add(RecordDefinition.class.getCanonicalName()); imports.add(RecordHeader.class.getCanonicalName()); //imports.add(GBToStringBuilder.class.getCanonicalName()); imports.addAll(getImports(outputFile)); Collections.sort(imports); final Set uniqueImports =new LinkedHashSet<>(imports); for (final GlobalFITMessage.FieldDefinitionPrimitive primitive : globalFITMessage.getFieldDefinitionPrimitives()) { final Class fieldType = getFieldType(primitive); if (!Objects.requireNonNull(fieldType.getCanonicalName()).startsWith("java.lang")) { imports.add(fieldType.getCanonicalName()); } } final StringBuilder sb = new StringBuilder(); String header = getHeader(outputFile); if (!header.isEmpty()) { sb.append(header); sb.append("\n"); } sb.append("package nodomain.freeyourgadget.gadgetbridge.service.devices.garmin.fit.messages;"); sb.append("\n"); sb.append("\n"); boolean anyImport = false; for (final String i : uniqueImports) { if (i.startsWith("androidx")) { sb.append("import ").append(i).append(";\n"); anyImport = true; } } if (anyImport) { sb.append("\n"); anyImport = false; } for (final String i : uniqueImports) { if (i.startsWith("java.")) { sb.append("import ").append(i).append(";\n"); anyImport = true; } } if (anyImport) { sb.append("\n"); anyImport = false; } for (final String i : uniqueImports) { if (i.startsWith("nodomain.freeyourgadget") && !i.startsWith("java.")) { sb.append("import ").append(i).append(";\n"); anyImport = true; } } if (anyImport) { sb.append("\n"); anyImport = false; } for (final String i : uniqueImports) { if (!i.startsWith("androidx") && !i.startsWith("nodomain.freeyourgadget") && !i.startsWith("java.")) { sb.append("import ").append(i).append(";\n"); anyImport = true; } } if (anyImport) { sb.append("\n"); } sb.append("//\n"); sb.append("// WARNING: This class was auto-generated, please avoid modifying it directly.\n"); sb.append("// See ").append(getClass().getCanonicalName()).append("\n"); sb.append("//\n"); sb.append("public class ").append(className).append(" extends RecordData {\n"); sb.append(" public ").append(className).append("(final RecordDefinition recordDefinition, final RecordHeader recordHeader) {\n"); sb.append(" super(recordDefinition, recordHeader);\n"); sb.append("\n"); sb.append(" final int globalNumber = recordDefinition.getGlobalFITMessage().getNumber();\n"); sb.append(" if (globalNumber != ").append(globalFITMessage.getNumber()).append(") {\n"); sb.append(" throw new IllegalArgumentException(\"").append(className).append(" expects global messages of \" + ").append(globalFITMessage.getNumber()).append(" + \", got \" + globalNumber);\n"); sb.append(" }\n"); sb.append(" }\n"); for (final GlobalFITMessage.FieldDefinitionPrimitive primitive : globalFITMessage.getFieldDefinitionPrimitives()) { final Class fieldType = getFieldType(primitive); final String fieldTypeName = fieldType.getSimpleName(); sb.append("\n"); sb.append(" @Nullable\n"); sb.append(" public ").append(fieldTypeName).append(method(" get", primitive)).append("() {\n"); if (fieldTypeName.endsWith("[]")) { // Special case for arrays, since these are decoded in RecordData and we can't easily decode them with the correct type // FIXME this should be refactored... final String simpleTypeName = fieldTypeName.replace("[]", ""); sb.append(" final Object[] objectsArray = (Object[]) getFieldByNumber(").append(primitive.getNumber()).append(");\n"); sb.append(" final ").append(fieldTypeName).append(" ret = new ").append(simpleTypeName).append("[objectsArray.length];\n"); sb.append(" for (int i = 0; i < objectsArray.length; i++) {\n"); sb.append(" ret[i] = (").append(simpleTypeName).append(") objectsArray[i];\n"); sb.append(" }\n"); sb.append(" return ret;\n"); } else { sb.append(" return (").append(fieldTypeName).append(") getFieldByNumber(").append(primitive.getNumber()).append(");\n"); } sb.append(" }\n"); } //sb.append("\n"); //sb.append(" @NonNull\n"); //sb.append(" @Override\n"); //sb.append(" public String toString() {\n"); //sb.append(" return new GBToStringBuilder(this)\n"); //for (final GlobalFITMessage.FieldDefinitionPrimitive primitive : globalFITMessage.getFieldDefinitionPrimitives()) { // sb.append(" .append(\"").append(primitive.getName()).append("\",").append(method(" get", primitive)).append("())\n"); //} //sb.append(" .build();\n"); //sb.append(" }\n"); if (outputFile.exists()) { // Keep manual changes if any final String fileContents = new String(Files.readAllBytes(outputFile.toPath()), StandardCharsets.UTF_8); final int manualChangesIndex = fileContents.indexOf("// manual changes below"); if (manualChangesIndex > 0) { sb.append("\n"); sb.append(" "); sb.append(fileContents.substring(manualChangesIndex)); } else { sb.append("}\n"); } } else { sb.append("}\n"); } FileUtils.copyStringToFile(sb.toString(), outputFile, "replace"); } public Class getFieldType(final GlobalFITMessage.FieldDefinitionPrimitive primitive) { if (primitive.getType() != null) { switch (primitive.getType()) { case ALARM: return Calendar.class; case ARRAY: return Number[].class; case DAY_OF_WEEK: return DayOfWeek.class; case FILE_TYPE: return FileType.FILETYPE.class; case GOAL_SOURCE: return FieldDefinitionGoalSource.Source.class; case GOAL_TYPE: return FieldDefinitionGoalType.Type.class; case HRV_STATUS: return FieldDefinitionHrvStatus.HrvStatus.class; case HR_TIME_IN_ZONE: return Double[].class; case HR_ZONE_HIGH_BOUNDARY: return Integer[].class; case MEASUREMENT_SYSTEM: return FieldDefinitionMeasurementSystem.Type.class; case TEMPERATURE: return Integer.class; case TIMESTAMP: return Long.class; case WEATHER_CONDITION: return FieldDefinitionWeatherCondition.Condition.class; case LANGUAGE: return FieldDefinitionLanguage.Language.class; case SLEEP_STAGE: return FieldDefinitionSleepStage.SleepStage.class; case WEATHER_AQI: return FieldDefinitionWeatherAqi.AQI_LEVELS.class; case COORDINATE: return Double.class; } throw new RuntimeException("Unknown field type " + primitive.getType()); } switch (primitive.getBaseType()) { case ENUM: case SINT8: case UINT8: case SINT16: case UINT16: case UINT8Z: case UINT16Z: case BASE_TYPE_BYTE: if (primitive.getScale() != 1) { return Float.class; } else { return Integer.class; } case SINT32: case UINT32: case UINT32Z: case SINT64: case UINT64: case UINT64Z: if (primitive.getScale() != 1) { return Double.class; } else { return Long.class; } case STRING: return String.class; case FLOAT32: return Float.class; case FLOAT64: return Double.class; } throw new RuntimeException("Unknown base type " + primitive.getBaseType()); } public String toCamelCase(final String str) { final StringBuilder sb = new StringBuilder(str.toLowerCase()); for (int i = 0; i < sb.length(); i++) { if (sb.charAt(i) == '_') { sb.deleteCharAt(i); sb.replace(i, i + 1, String.valueOf(Character.toUpperCase(sb.charAt(i)))); } } return sb.toString(); } public String method(final String methodName, final GlobalFITMessage.FieldDefinitionPrimitive primitive) { return methodName + capitalize(toCamelCase(primitive.getName())); } public String capitalize(final String str) { return str.substring(0, 1).toUpperCase() + str.substring(1); } public String getHeader(final File file) throws IOException { if (file.exists()) { final String fileContents = new String(Files.readAllBytes(file.toPath()), StandardCharsets.UTF_8); final int packageIndex = fileContents.indexOf("package") - 1; if (packageIndex > 0) { return fileContents.substring(0, packageIndex); } } return ""; } public List getImports(final File file) throws IOException { if (file.exists()) { final String fileContents = new String(Files.readAllBytes(file.toPath()), StandardCharsets.UTF_8); final List imports = new ArrayList<>(); final Matcher m = Pattern.compile("import (.+);") .matcher(fileContents); while (m.find()) { imports.add(m.group(1)); } return imports; } return Collections.emptyList(); } }