diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 88bb1723..a590bca1 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -29,7 +29,9 @@ jobs: - name: Validate Dictionaries run: ./gradlew validateLanguages - name: Build Languages - run: ./gradlew copyDefinitions convertHelp buildDictionaryDownloads copyDownloadsToAssets + run: ./gradlew buildDefinition buildDictionaryDownloads + - name: Copy Downloads + run: ./gradlew copyDownloadsToAssets - name: Lint run: ./gradlew lint # this actually runs mergeResources, so it must come after the dictionary tasks - name: Build all APK variants diff --git a/app/build-definitions.gradle b/app/build-definitions.gradle new file mode 100644 index 00000000..17f02bca --- /dev/null +++ b/app/build-definitions.gradle @@ -0,0 +1,22 @@ +ext.mergeDefinitions = { String definitionsInputDir, String definitionsOutputPath -> + def merged = new File(definitionsOutputPath) + merged.delete() + + boolean isFirst = true + fileTree(dir: definitionsInputDir).getFiles().each { file -> + if (!file.isFile() || !file.name.endsWith(".yml")) { + return + } + + if (isFirst) { + isFirst = false + } else { + merged << "\n---\n" + } + + merged << file.text + .replaceAll("\\s*#[^\n]+", "") + .replaceAll("^[ ]+\n", "") + .trim() + } +} diff --git a/app/build-dictionary.gradle b/app/build-dictionaries.gradle similarity index 100% rename from app/build-dictionary.gradle rename to app/build-dictionaries.gradle diff --git a/app/build.gradle b/app/build.gradle index 07ea2f53..7cdd04d1 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -3,19 +3,13 @@ plugins { } apply from: 'constants.gradle' -apply from: 'build-dictionary.gradle' +apply from: 'build-definitions.gradle' +apply from: 'build-dictionaries.gradle' apply from: 'validate-languages.gradle' apply from: 'help-tools.gradle' apply from: 'version-tools.gradle' -tasks.register('copyDefinitions', Copy) { - from LANGUAGES_INPUT_DIR - include '**/*.yml' - into LANGUAGES_OUTPUT_DIR -} - - tasks.register('validateLanguages') { inputs.dir LANGUAGES_INPUT_DIR outputs.dir LANGUAGE_VALIDATION_DIR @@ -26,8 +20,18 @@ tasks.register('validateLanguages') { } +tasks.register('buildDefinition') { + inputs.dir DEFINITIONS_INPUT_DIR + outputs.file DEFINITIONS_OUTPUT_FILE + + doLast { + mergeDefinitions(DEFINITIONS_INPUT_DIR, DEFINITIONS_OUTPUT_FILE) + } +} + + tasks.register('buildDictionaryDownloads') { - inputs.dir DICTIONARIES_INPUT_DIR + inputs.dir LANGUAGES_INPUT_DIR outputs.dir DICTIONARIES_DOWNLOAD_DIR outputs.dir DICTIONARY_META_OUTPUT_DIR @@ -139,7 +143,7 @@ android { ].each { taskName -> try { tasks.named(taskName)?.configure { - dependsOn(copyDefinitions, convertHelp, validateLanguages, buildDictionaryDownloads) + dependsOn(buildDefinition, convertHelp, validateLanguages, buildDictionaryDownloads) } if (taskName.toLowerCase().contains("full")) { diff --git a/app/constants.gradle b/app/constants.gradle index aca5916d..b70542e0 100644 --- a/app/constants.gradle +++ b/app/constants.gradle @@ -21,7 +21,7 @@ ext.DICTIONARIES_INPUT_DIR = "${LANGUAGES_INPUT_DIR}/${DICTIONARIES_DIR_NAME}" ext.DICTIONARIES_DOWNLOAD_DIR = "${project.rootDir}/${DICTIONARIES_DOWNLOAD_DIR_NAME}" ext.LANGUAGES_OUTPUT_DIR = "${MAIN_ASSETS_DIR}/${LANGUAGES_DIR_NAME}" -ext.DEFINITIONS_OUTPUT_DIR = "${LANGUAGES_OUTPUT_DIR}/${DEFINITIONS_DIR_NAME}" +ext.DEFINITIONS_OUTPUT_FILE = "${LANGUAGES_OUTPUT_DIR}/${DEFINITIONS_DIR_NAME}.yml" ext.DICTIONARY_META_OUTPUT_DIR = "${LANGUAGES_OUTPUT_DIR}/${DICTIONARIES_DIR_NAME}" ext.DICTIONARIES_OUTPUT_DIR = "${FULL_VERSION_ASSETS_DIR}/${LANGUAGES_DIR_NAME}/${DICTIONARIES_DIR_NAME}" diff --git a/app/src/main/java/io/github/sspanak/tt9/languages/LanguageCollection.java b/app/src/main/java/io/github/sspanak/tt9/languages/LanguageCollection.java index ddcfdbaf..1b77a836 100644 --- a/app/src/main/java/io/github/sspanak/tt9/languages/LanguageCollection.java +++ b/app/src/main/java/io/github/sspanak/tt9/languages/LanguageCollection.java @@ -19,12 +19,12 @@ public class LanguageCollection { private final HashMap languages = new HashMap<>(); private LanguageCollection(Context context) { - for (String file : LanguageDefinition.getAllFiles(context.getAssets())) { + for (LanguageDefinition definition : LanguageDefinition.getAll(context.getAssets())) { try { - NaturalLanguage lang = NaturalLanguage.fromDefinition(LanguageDefinition.fromFile(context.getAssets(), file)); + NaturalLanguage lang = NaturalLanguage.fromDefinition(definition); languages.put(lang.getId(), lang); } catch (Exception e) { - Logger.e("tt9.LanguageCollection", "Skipping invalid language: '" + file + "'. " + e.getMessage()); + Logger.e("tt9.LanguageCollection", "Skipping invalid language: '" + definition.name + "'. " + e.getMessage()); } } } diff --git a/app/src/main/java/io/github/sspanak/tt9/languages/LanguageDefinition.java b/app/src/main/java/io/github/sspanak/tt9/languages/LanguageDefinition.java index 5b6f354c..4547f381 100644 --- a/app/src/main/java/io/github/sspanak/tt9/languages/LanguageDefinition.java +++ b/app/src/main/java/io/github/sspanak/tt9/languages/LanguageDefinition.java @@ -8,17 +8,17 @@ import androidx.annotation.Nullable; import java.io.BufferedReader; import java.io.IOException; import java.util.ArrayList; -import java.util.Arrays; import io.github.sspanak.tt9.BuildConfig; import io.github.sspanak.tt9.util.AssetFile; import io.github.sspanak.tt9.util.Logger; -public class LanguageDefinition extends AssetFile { +public class LanguageDefinition { private static final String LOG_TAG = LanguageDefinition.class.getSimpleName(); - private static final String languagesDir = "languages"; - private static final String definitionsDir = languagesDir + "/definitions"; + private static final String LANGUAGES_DIR = "languages"; + private static final String DEFINITIONS_PATH = LANGUAGES_DIR + "/definitions.yml"; + private static final String YAML_SEPARATOR = "---"; public String abcString = ""; public String currency = ""; @@ -30,186 +30,181 @@ public class LanguageDefinition extends AssetFile { public String locale = ""; public String name = ""; + private boolean inLayout = false; - public LanguageDefinition(AssetManager assets, String name) { - super(assets, definitionsDir + "/" + name); - } /** - * getAllFiles - * Returns a list of the paths of all language definition files in the assets folder or an empty list on error. - */ - public static ArrayList getAllFiles(AssetManager assets) { - ArrayList files = new ArrayList<>(); - try { - files.addAll(Arrays.asList(assets.list(definitionsDir))); - Logger.d(LOG_TAG, "Found: " + files.size() + " languages."); - } catch (IOException | NullPointerException e) { - Logger.e(LOG_TAG, "Failed reading language definitions from: '" + definitionsDir + "'. " + e.getMessage()); - } - - return files; - } - - - /** - * fromFile - * Takes the path to a language definition in the assets folder and parses that file into a LanguageDefinition - * or throws an IOException on error. - */ - public static LanguageDefinition fromFile(AssetManager assetManager, String definitionFile) throws IOException { - LanguageDefinition definition = new LanguageDefinition(assetManager, definitionFile); - definition.parse(definition.load(definition)); - return definition; - } - - - /** - * load - * Loads a language definition file from the assets folder into a String or throws an IOException on error. - */ - private ArrayList load(LanguageDefinition definitionFile) throws IOException { - BufferedReader reader = definitionFile.getReader(); - ArrayList fileContents = new ArrayList<>(); - String line; - while ((line = reader.readLine()) != null) { - fileContents.add(line); - } - - return fileContents; - } - - - /** - * parse - * Converts "yaml" to a LanguageDefinition object. All properties in the YAML are considered optional, - * so the LanguageDefinition defaults will be used when some property is omitted. + * Converts YAML definitions to a LanguageDefinition objects. All properties in the YAML are + * considered optional, so the LanguageDefinition defaults will be used when some property is omitted. * Had to write all this, because the only usable library, SnakeYAML, works fine on Android 10+, * but causes crashes on older devices. */ + private LanguageDefinition() {} - private void parse(ArrayList yaml) { - abcString = getPropertyFromYaml(yaml, "abcString", abcString); - currency = getPropertyFromYaml(yaml, "currency", currency); - dictionaryFile = getPropertyFromYaml(yaml, "dictionaryFile", dictionaryFile); - if (dictionaryFile != null) { - dictionaryFile = dictionaryFile.replaceFirst("\\.\\w+$", "." + BuildConfig.DICTIONARY_EXTENSION); + /** + * Returns a list of all language definitions contained in the asset at DEFINITIONS_PATH, + * or an empty list on error. + */ + public static ArrayList getAll(AssetManager assets) { + String[] definitionLines = readDefinitions(assets); + if (definitionLines.length == 0) { + return new ArrayList<>(); } - hasSpaceBetweenWords = getPropertyFromYaml(yaml, "hasSpaceBetweenWords", hasSpaceBetweenWords); - hasUpperCase = getPropertyFromYaml(yaml, "hasUpperCase", hasUpperCase); - isSyllabary = hasYamlProperty(yaml, "sounds"); - layout = getLayoutFromYaml(yaml); - locale = getPropertyFromYaml(yaml, "locale", locale); - name = getPropertyFromYaml(yaml, "name", name); + ArrayList definitions = new ArrayList<>(); + + LanguageDefinition definition = new LanguageDefinition(); + for (String line : definitionLines) { + if (YAML_SEPARATOR.equals(line)) { + definitions.add(definition); + definition = new LanguageDefinition(); + } else if (!definition.setLayoutEntry(line)) { + definition.setProperty(line); + } + } + + definitions.add(definition); + + Logger.d("tt9.LanguageCollection", "Found " + definitions.size() + " languages"); + return definitions; } /** - * getPropertyFromYaml - * Finds "property" in the "yaml" and returns its value. - * Optional properties are allowed. If the property is not found, "defaultValue" will be returned. + * Reads the language definitions from DEFINITIONS_PATH and returns them as an array of strings. */ - @Nullable - private String getPropertyFromYaml(ArrayList yaml, String property, String defaultValue) { - for (String line : yaml) { - line = line.replaceAll("#.+$", "").trim(); - String[] parts = line.split(":"); - if (parts.length < 2) { - continue; + private static String[] readDefinitions(AssetManager assets) { + try { + BufferedReader reader = new AssetFile(assets, DEFINITIONS_PATH).getReader(); + StringBuilder contents = new StringBuilder(); + char[] buffer = new char[10000]; + int read; + + while ((read = reader.read(buffer)) != -1) { + contents.append(buffer, 0, read); } - if (property.equals(parts[0].trim())) { - return parts[1].trim(); - } + return contents.toString().split("\n"); + } catch (IOException e) { + Logger.e(LOG_TAG, "Failed reading language definitions from: '" + DEFINITIONS_PATH + "'. " + e.getMessage()); + return new String[0]; } - - return defaultValue; } - private boolean hasYamlProperty(ArrayList yaml, String property) { - final String yamlProperty = property + ":"; + /** + * Normalizes a YAML boolean to a Java boolean. + */ + private boolean parseYamlBoolean(@Nullable String value) { + if (value == null) { + return false; + } - for (String line : yaml) { - if (line.startsWith(yamlProperty)) { - return true; - } + return switch (value.toLowerCase()) { + case "true", "on", "yes", "y" -> true; + default -> false; + }; + } + + + /** + * Sets a property based on the key-value pair in a YAML line. If the key does not match any + * property, the line is ignored. + */ + private void setProperty(@NonNull String line) { + int colonIndex = line.indexOf(':'); + if (colonIndex == -1) { + return; + } + + String key = line.substring(0, colonIndex).trim(); + String value = (colonIndex + 1 < line.length()) ? line.substring(colonIndex + 1).trim() : ""; + + switch (key) { + case "abcString": + abcString = value; + break; + case "currency": + currency = value; + break; + case "dictionaryFile": + dictionaryFile = value.replaceFirst("\\.\\w+$", "." + BuildConfig.DICTIONARY_EXTENSION); + break; + case "hasSpaceBetweenWords": + hasSpaceBetweenWords = parseYamlBoolean(value); + break; + case "hasUpperCase": + hasUpperCase = parseYamlBoolean(value); + break; + case "sounds": + isSyllabary = true; + break; + case "locale": + locale = value; + break; + case "name": + name = value; + break; + } + } + + + /** + * Builds the key layout line by line. Returns true when a layout entry is successfully set. + */ + private boolean setLayoutEntry(@NonNull String line) { + if (!inLayout) { + return inLayout = "layout:".equals(line); + } + + ArrayList layoutEntry = getLayoutEntryFromYamlLine(line); + if (layoutEntry == null) { + inLayout = false; + } else { + layout.add(layoutEntry); + return true; } return false; } - /** - * The boolean variant of getPropertyFromYaml. It returns true if the property is found and is: - * "true", "on", "yes" or "y". - */ - private boolean getPropertyFromYaml(ArrayList yaml, String property, boolean defaultValue) { - String value = getPropertyFromYaml(yaml, property, null); - if (value == null) { - return defaultValue; - } - - value = value.toLowerCase(); - return value.equals("true") || value.equals("on") || value.equals("yes") || value.equals("y"); - } - - - /** - * getLayoutFromYaml - * Finds and extracts the keypad layout. Less than 10 keys are accepted allowed leaving the ones up to 9-key empty. - */ - @NonNull - private ArrayList> getLayoutFromYaml(ArrayList yaml) { - ArrayList> layout = new ArrayList<>(); - - boolean inLayout = false; - for (int i = 0; i < yaml.size(); i++) { - if (yaml.get(i).contains("layout")) { - inLayout = true; - continue; - } - - if (inLayout) { - ArrayList lineChars = getLayoutEntryFromYamlLine(yaml.get(i)); - if (lineChars != null) { - layout.add(lineChars); - } else { - break; - } - } - } - - return layout; - } - - /** * getLayoutEntryFromYamlLine * Validates a YAML line as an array and returns the character list to be assigned to a given key (a layout entry). * If the YAML line is invalid, NULL will be returned. */ @Nullable - private ArrayList getLayoutEntryFromYamlLine(String yamlLine) { - if (!yamlLine.contains("[") || !yamlLine.contains("]")) { + private ArrayList getLayoutEntryFromYamlLine(@NonNull String yamlLine) { + int start = yamlLine.indexOf('['); + int end = yamlLine.indexOf(']'); + if (start == -1 || end == -1 || start >= end) { return null; } - String line = yamlLine - .replaceAll("#.+$", "") - .replace('-', ' ') - .replace('[', ' ') - .replace(']', ' ') - .replace(" ", ""); + String entryTxt = yamlLine.substring(start + 1, end).replace(" ", ""); - return new ArrayList<>(Arrays.asList(line.split(","))); + ArrayList entry = new ArrayList<>(); + int last = 0, len = entryTxt.length(); + + for (int i = 0; i < len; i++) { + if (entryTxt.charAt(i) == ',') { + entry.add(entryTxt.substring(last, i)); + last = i + 1; + } + } + + if (last < len) { + entry.add(entryTxt.substring(last)); + } + + return entry; } public String getDictionaryFile() { - return languagesDir + "/dictionaries/" + dictionaryFile; + return LANGUAGES_DIR + "/dictionaries/" + dictionaryFile; } }