Skip to content
This repository has been archived by the owner on Jun 11, 2023. It is now read-only.
/ lib-i18n Public archive

The library `Lib-I18N` allows a developer to bind a key-value pair of a `.properties` file to a [StringBinding]. This makes it very easy to change the language during runtime in a [JavaFX] application.

License

Notifications You must be signed in to change notification settings

Naoghuman/lib-i18n

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Lib-I18n

Build Status license: GPL v3 GitHub release

Intention

The library Lib-I18N allows a developer to bind a key-value pair of a .properties file to a StringBinding. This makes it very easy to change the language during runtime in a JavaFX application.
Lib-I18N is written in JavaFX, Maven and NetBeans.

Image: Demo integration from Lib-I18N
Lib-I18N_Demo_v0.8.0_2019-04-27_16-24.png

The demo shows how easy an application becomes multilingual in four steps 😄 .
Plz see the section Demo for more informations.

Content

Features

Main library features

In this sub-section all main features from the library 'Lib-I18N' are listed:

  1. The library Lib-I18N allows a developer to bind a key with its associated value of a .properties file to a StringBinding.
  2. With the builder I18NResourceBundleBuilder the developer can configure the ResourceBundle which contains the key - value pairs which will then be bind to a actual Locale.
  3. The builder I18NBindingBuilder let the developer create a StringBinding. The StringBinding can created with a function from type Callable<String> or with a .properties key and optional arguments.
  4. To load a .properties key with optional arguments from the initialized ResourceBundle through the I18NResourceBundleBuilder the developer can use the builder I18NMessageBuilder.

General library features

This sub-section list all general features from the library 'Lib-I18N':

  1. The library Lib-I18N is open source and licensed under General Public License 3.0.
  2. The library is written in Java and JavaFX.
  3. The library is programmed with the IDE NetBeans as a Maven library.
  4. The library can easily integrated into a foreign project over Maven Central.
  5. Due to the connection of the project with Travis CI, a build is automatically executed at each commit.
  6. By integrating various "badges" from "img.shield.io", interested readers can easily find out about the "build" state, the current version and the license used for this library.
  7. All functionalities from the classes in the core and internal packages are tested with Unittests.
  8. Every parameter in all functionalities will be verified against minimal conditions with the internal validator DefaultI18NValidator. For example a String can't be NULL or EMPTY.
  9. The documentation from the library is available with an extended ReadMe and well-described JavaDoc comments.

Conventions

In this chapter, the interested developer can find out all about the conventions from the library Lib-I18N:

Convention: 'baseBundleName' from ResourceBundle

If a ResourceBundle with the defined 'baseBundleName' can't be found a spezific MissingResourceException will be thrown.

public final class DefaultI18NResourceBundle implements I18NResourceBundle {
    ...
    private ResourceBundle getResourceBundle() {
        ResourceBundle bundle = null;
        try {
            bundle = ResourceBundle.getBundle(this.getBaseBundleName(), this.getActualLocale());
        } catch (MissingResourceException mre) {
            LoggerFacade.getDefault().error(this.getClass(), 
                    String.format("Can't access the ResourceBundle[path=%s, actual-locale=%s]", 
                            this.getBaseBundleName(), this.getActualLocale().getDisplayLanguage()), 
                    mre);
        }
        
        return bundle;
    }
    ...
}

Convention: 'Key not found' in ResourceBundle

If a key can't be found in the defined ResourceBundle then

  • the String pattern '<key>' will returned and
  • the following 'warning' message will be logged: "Can't find key(%s) in resourcebundle. Return: %s"
2019-04-19 22:36:34,708  WARN  Can't find key(not.existing.key.in.existing.resourcebundle) in resourcebundle. Return: <not.existing.key.in.existing.resourcebundle>     [DefaultI18NResourceBundle]
java.util.MissingResourceException: Can't find resource for bundle java.util.PropertyResourceBundle, key not.existing.key.in.existing.resourcebundle
	at java.util.ResourceBundle.getObject(ResourceBundle.java:450) ~[?:1.8.0_201]
	at java.util.ResourceBundle.getString(ResourceBundle.java:407) ~[?:1.8.0_201]
	at com.github.naoghuman.lib.i18n.internal.DefaultI18NResourceBundle.getMessage(DefaultI18NResourceBundle.java:96) [classes/:?]
	at com.github.naoghuman.lib.i18n.internal.DefaultI18NResourceBundleTest.getMessageWithResourceBundleThrowsMissingResourceException(DefaultI18NResourceBundleTest.java:110) [test-classes/:?]
	at sun.reflect.NativeMethodAccessorImpl.invoke0(Native Method) ~[?:1.8.0_201]
        ...
public final class DefaultI18NResourceBundle implements I18NResourceBundle {

    private static final String PATTERN_KEY_NAME = "<{0}>"; // NO18N
    ...
    @Override
    public String getMessage(final String key, final Object... arguments) {
        DefaultI18NValidator.requireNonNullAndNotEmpty(key);
        DefaultI18NValidator.requireNonNullAndNotEmpty(arguments);
        DefaultI18NValidator.requireResourceBundleExist(this.getBaseBundleName(), this.getActualLocale());
        
        final ResourceBundle bundle = getResourceBundle();
        String value = MessageFormat.format(PATTERN_KEY_NAME, key);
        
        if (bundle != null) {
            try {
                value = MessageFormat.format(bundle.getString(key), arguments);
            } catch (MissingResourceException mre) {
                LoggerFacade.getDefault().warn(this.getClass(), 
                        String.format("Can't find key(%s) in resourcebundle. Return: %s", key, value), 
                        mre);
            }
        }
        
        return value;
    }
    ...
}

Convention: Defined supported Locales, default and actual Locale

Supported Locales

  • Defines all supported Locales in the momentary session.
  • This array should reflectes all your defined languages .properties files.
public final class I18NResourceBundleBuilder {
    ...
    private static final class I18NResourceBundleBuilderImpl implements
            FirstStep,  ForthStep, LastStep, SecondStep, ThirdStep
    {
        ...
        @Override
        public ThirdStep supportedLocales(final Locale... locales) {
            LoggerFacade.getDefault().debug(this.getClass(), "I18NResourceBundleBuilderImpl.supportedLocales(Locale...)"); // NOI18N
            
            final List<Locale> list = Arrays.asList(locales);
            DefaultI18NValidator.requireNonNullAndNotEmpty(list);
            
            final ObservableList<Locale> observableList = FXCollections.observableArrayList();
            observableList.addAll(list);
            properties.put(ATTR__SUPPORTED_LOCALES, new SimpleObjectProperty(observableList));
            
            return this;
        }

        @Override
        public ThirdStep supportedLocales(final ObservableList<Locale> locales) {
            LoggerFacade.getDefault().debug(this.getClass(), "I18NResourceBundleBuilderImpl.supportedLocales(ObservableList<Locale>)"); // NOI18N
            
            DefaultI18NValidator.requireNonNullAndNotEmpty(locales);
            properties.put(ATTR__SUPPORTED_LOCALES, new SimpleObjectProperty(locales));
            
            return this;
        }
        ...
    }
}
public final class I18NFacade implements I18NBinding, I18NResourceBundle {
    ...
    @Override
    public ObservableList<Locale> getSupportedLocales() {
        return i18NResourceBundle.getSupportedLocales();
    }

    @Override
    public void setSupportedLocales(final ObservableList<Locale> locales) {
        i18NResourceBundle.setSupportedLocales(locales);
    }

    @Override
    public void setSupportedLocales(final Locale... locales) {
        i18NResourceBundle.setSupportedLocales(locales);
    }
    ...
}
public final class DefaultI18NResourceBundle implements I18NResourceBundle {
    ...
    @Override
    public ObservableList<Locale> getSupportedLocales() {
        DefaultI18NValidator.requireNonNull(supportedLocales);
        
        return supportedLocales;
    }

    @Override
    public void setSupportedLocales(final ObservableList<Locale> locales) {
        DefaultI18NValidator.requireNonNullAndNotEmpty(locales);
        
        supportedLocales.clear();
        supportedLocales.addAll(locales);
    }

    @Override
    public void setSupportedLocales(final Locale... locales) {
        final List<Locale> list = Arrays.asList(locales);
        DefaultI18NValidator.requireNonNullAndNotEmpty(list);
        
        supportedLocales.clear();
        supportedLocales.addAll(list);
    }
    ...
}

Default Locale

  • If the supported Locales doesn't contained the default Locale then the Locale#ENGLISH instead will be return.
public final class DefaultI18NResourceBundle implements I18NResourceBundle {
    ...
    @Override
    public void setDefaultLocale(final Locale locale) {
        DefaultI18NValidator.requireNonNull(locale);
        
        defaultLocale = this.getSupportedLocales().contains(locale) ? locale : Locale.ENGLISH;
    }
    ...
}

Actual Locale

  • If the supported Locales doesn't contained the actual Locale then the default Locale instead will be return.
public final class DefaultI18NResourceBundle implements I18NResourceBundle {
    ...
    @Override
    public void setActualLocale(final Locale locale) {
        DefaultI18NValidator.requireNonNull(locale);
        
        actualLocaleProperty.set(this.getSupportedLocales().contains(locale) ? locale : defaultLocale);
    }
    ...
}

Convention: Basic validation

  • Every functionality in the builders and in the default implementations will checked against minimal preconditions with the validator DefaultI18NValidator.
  • Getters and Setters functionalities are only checked if they are initial only instantiate and not declarated.
  • For example a String will be validate if it's not NULL and not EMPTY.
public final class I18NResourceBundleBuilder {
    ...
    private static final class I18NResourceBundleBuilderImpl implements
            FirstStep,  ForthStep, LastStep, SecondStep, ThirdStep
    {
        ...
        @Override
        public SecondStep baseBundleName(final String baseBundleName) {
            LoggerFacade.getDefault().debug(this.getClass(), "I18NResourceBundleBuilderImpl.baseBundleName(String)"); // NOI18N

            DefaultI18NValidator.requireNonNullAndNotEmpty(baseBundleName);
            properties.put(ATTR__BASE_BUNDLE_NAME, new SimpleStringProperty(baseBundleName));

            return this;
        }
        ...
    }
}
public final class DefaultI18NResourceBundle implements I18NResourceBundle {
    ...
    @Override
    public String getBaseBundleName() {
        DefaultI18NValidator.requireNonNullAndNotEmpty(baseBundleName);
        
        return baseBundleName;
    }

    @Override
    public void setBaseBundleName(final String baseBundleName) {
        DefaultI18NValidator.requireNonNullAndNotEmpty(baseBundleName);
        
        this.baseBundleName = baseBundleName;
    }
    ...
}
public final class DefaultI18NBinding implements I18NBinding {
    ...
    @Override
    public StringBinding createStringBinding(final String key, Object... arguments) {
        DefaultI18NValidator.requireNonNullAndNotEmpty(key);
        DefaultI18NValidator.requireNonNullAndNotEmpty(arguments);
        
        return Bindings.createStringBinding(() -> I18NFacade.getDefault().getMessage(key, arguments), I18NFacade.getDefault().actualLocaleProperty());
    }
    ...
}

Examples

How to use the builder I18NResourceBundleBuilder

With the builder I18NResourceBundleBuilder the developer can configure the ResourceBundle which contains the key - value terms which will then be bind to a Locale. That means switching the actual Locale update all binded textes with the specific values from the corresponding language .properties file.

Specification: Usage of I18NResourceBundleBuilder

/**
 * 1) Starts the configuration process.
 * 2) Defines the path and name from the .properties file.
 * 3) Sets all supported Locales with an [].
 * 4) Sets all supported Locales with an ObservableList.
 * 5) Sets the default Locale.
 * 6) Sets the actual Locale.
 * 7) Completes the configuration process.
 */
I18NResourceBundleBuilder.configure() // 1
        .baseBundleName(String)       // 2
        .supportedLocales(Locale...)  // 3
        .supportedLocales(ObservableList<Locale>) // 4
        .defaultLocale(Locale)        // 5
        .actualLocale(Locale)         // 6
        .build();                     // 7

Examples:

@Test
public void lastStepWithSupportedLocalesAsArray() {
    String resourcbundle = "com.github.naoghuman.lib.i18n.internal.resourcebundle";
    I18NResourceBundleBuilder.configure()
            .baseBundleName(resourcbundle)
            .supportedLocales(Locale.ITALIAN, Locale.JAPANESE)
            .defaultLocale(Locale.ITALIAN)
            .actualLocale(Locale.JAPANESE)
            .build();

    assertEquals(resourcbundle,   I18NFacade.getDefault().getBaseBundleName());
    assertEquals(Locale.ITALIAN,  I18NFacade.getDefault().getDefaultLocale());
    assertEquals(Locale.JAPANESE, I18NFacade.getDefault().getActualLocale());
    assertEquals(2,               I18NFacade.getDefault().getSupportedLocales().size());
}

@Test
public void lastStepWithSupportedLocalesAsObservableList() {
    String resourcbundle = "com.github.naoghuman.lib.i18n.internal.resourcebundle";
    final ObservableList<Locale> locales = FXCollections.observableArrayList();
    locales.addAll(Locale.ITALIAN, Locale.JAPANESE, Locale.FRENCH);
    I18NResourceBundleBuilder.configure()
            .baseBundleName(resourcbundle)
            .supportedLocales(locales)
            .defaultLocale(Locale.ITALIAN)
            .actualLocale(Locale.JAPANESE)
            .build();

    assertEquals(resourcbundle,  I18NFacade.getDefault().getBaseBundleName());
    assertEquals(Locale.ITALIAN, I18NFacade.getDefault().getDefaultLocale());
    assertEquals(Locale.JAPANESE,I18NFacade.getDefault().getActualLocale());
    assertEquals(3,              I18NFacade.getDefault().getSupportedLocales().size());
}

How to use the builder I18NBindingBuilder

The builder I18NBindingBuilder let the developer create a StringBinding. The StringBinding can created with a function from type Callable<String> or with a .properties key and optional arguments.

Specification: Usage of I18NBindingBuilder

/**
 * 1) Starts the binding process.
 * 2) Use the given function to create a StringBinding.
 * 3) Completes the binding process and returns the StringBinding.
 */
I18NBindingBuilder.bind()          // 1
       .callable(Callable<String>) // 2
       .build();                   // 3

/**
 * 1) Starts the binding process.
 * 2) Defines the key which value will be bind to the StringBinding.
 * 3) Optional arguments for the value from the given key.
 * 4) Completes the binding process and returns the StringBinding.
 */
I18NBindingBuilder.bind()         // 1
       .key(String)               // 2
       .arguments(Object... args) // 3
       .build();                  // 4

Examples:

@Test
public void lastStepCallable() {
    I18NResourceBundleBuilder.configure()
            .baseBundleName("com.github.naoghuman.lib.i18n.internal.resourcebundle")
            .supportedLocales(Locale.ENGLISH, Locale.GERMAN)
            .defaultLocale(Locale.ENGLISH)
            .actualLocale(Locale.GERMAN)
            .build();

    Optional<StringBinding> result = I18NBindingBuilder.bind()
            .callable(() -> I18NMessageBuilder.message()
                    .key("resourcebundle.title")
                    .build()
            )
            .build();
    assertTrue(result.isPresent());
    assertEquals("RB: Test Titel", result.get().get());

    I18NFacade.getDefault().setActualLocale(Locale.ENGLISH); // Here the magic happen :)
    assertEquals("RB: Test title", result.get().get());
}

@Test
public void lastStepKeyWithoutArguments() {
    I18NResourceBundleBuilder.configure()
            .baseBundleName("com.github.naoghuman.lib.i18n.internal.resourcebundle")
            .supportedLocales(Locale.ENGLISH, Locale.GERMAN)
            .defaultLocale(Locale.ENGLISH)
            .actualLocale(Locale.GERMAN)
            .build();

    Optional<StringBinding> result = I18NBindingBuilder.bind()
            .key("resourcebundle.title")
            .build();
    assertTrue(result.isPresent());
    assertEquals("RB: Test Titel", result.get().get());

    I18NFacade.getDefault().setActualLocale(Locale.ENGLISH); // Here the magic happen :)
    assertEquals("RB: Test title", result.get().get());
}

@Test
public void lastStepKeyWithArguments() {
    I18NResourceBundleBuilder.configure()
            .baseBundleName("com.github.naoghuman.lib.i18n.internal.resourcebundle")
            .supportedLocales(Locale.ENGLISH, Locale.GERMAN)
            .defaultLocale(Locale.ENGLISH)
            .actualLocale(Locale.GERMAN)
            .build();

    Optional<StringBinding> result = I18NBindingBuilder.bind()
            .key("resourcebundle.label.with.parameter")
            .arguments(123)
            .build();
    assertTrue(result.isPresent());
    assertEquals("RB: Text mit Parameter: 123", result.get().get());

    I18NFacade.getDefault().setActualLocale(Locale.ENGLISH); // Here the magic happen :)
    assertEquals("RB: Text with parameter: 123", result.get().get());
}

How to use the builder I18NMessageBuilder

To load a .properties key with optional arguments from the initialized ResourceBundle through the I18NResourceBundleBuilder the developer can use the builder I18NMessageBuilder.

Specification: Usage of I18NMessageBuilder

/**
 * 1) Starts the message process.
 * 2) Defines the key which value will be loaded.
 * 3) Optional arguments for the value from the given key.
 * 4) Completes the message process and returns a String.
 */
I18NMessageBuilder.message()  // 1
        .key(String)          // 2
        .arguments(Object...) // 3
        .build();             // 4

Examples:

@Test
public void lastStepWithoutArguments() {
    I18NResourceBundleBuilder.configure()
            .baseBundleName("com.github.naoghuman.lib.i18n.internal.resourcebundle")
            .supportedLocales(Locale.ENGLISH, Locale.GERMAN)
            .defaultLocale(Locale.ENGLISH)
            .actualLocale(Locale.GERMAN)
            .build();

    String result = I18NMessageBuilder.message()
            .key("resourcebundle.title")
            .build();
    assertEquals("RB: Test Titel", result);

    I18NFacade.getDefault().setActualLocale(Locale.ENGLISH); // Here the magic happen :)
    result = I18NMessageBuilder.message()
            .key("resourcebundle.title")
            .build();
    assertEquals("RB: Test title", result);
}
    
@Test
public void lastStepWithArguments() {
    I18NResourceBundleBuilder.configure()
            .baseBundleName("com.github.naoghuman.lib.i18n.internal.resourcebundle")
            .supportedLocales(Locale.ENGLISH, Locale.GERMAN)
            .defaultLocale(Locale.ENGLISH)
            .actualLocale(Locale.GERMAN)
            .build();

    String result = I18NMessageBuilder.message()
            .key("resourcebundle.label.with.parameter")
            .arguments(2)
            .build();
    assertEquals("RB: Text mit Parameter: 2", result);

    I18NFacade.getDefault().setActualLocale(Locale.ENGLISH); // Here the magic happen :)
    result = I18NMessageBuilder.message()
            .key("resourcebundle.label.with.parameter")
            .arguments(123)
            .build();
    assertEquals("RB: Text with parameter: 123", result);
}

Demo

The demo applications shows how to integrate the library Lib-I18N in four simple steps.

Image: Demo application Lib-I18N_Demo-English_v0.8.0_2019-04-27_16-14.png

Hint:
To run the demo local the library Lib-FMXL v0.3.0-PRERELEASE is needed which isn't momentary available in Maven Central. The library can be downloaded manually here: https://github.com/Naoghuman/lib-fxml

Step one: Prepare your application

First inject the library 'Lib-I18N' into your project.
Then create for every supported language a .properties file.

<dependencies>
    <dependency>
        <groupId>com.github.naoghuman</groupId>
        <artifactId>lib-i18n</artifactId>
        <version>0.8.0</version>
    </dependency>
</dependencies>

Image: Demo .properties files Lib-I18N_Demo-properties-files_v0.8.0_2019-04-27_17-33.png

Step two: Register the ResourceBundle

Next register the ResourceBundle where supportedLocales are corresponding to every existing .properties file and actualLocale is the language which will shown first.

public final class DemoI18NStart extends Application {

    @Override
    public void init() throws Exception {
        I18NResourceBundleBuilder.configure()
                .baseBundleName("com.github.naoghuman.lib.i18n.demo_i18n") // NOI18N
                .supportedLocales(Locale.ENGLISH, Locale.FRENCH, Locale.GERMAN, Locale.ITALIAN)
                .defaultLocale(Locale.ENGLISH)
                .actualLocale(Locale.ENGLISH)
                .build();
    }
}

Step three: Bind the text components

In the third step the text components will be bind to the depending key from the ResourceBundle.

public final class DemoI18NController extends FXMLController implements Initializable {

    @FXML private Button bFrench;
    @FXML private Button bGerman;
    @FXML private Button bItalian;
    @FXML private Button bEnglish;
    @FXML private Label lLanguages;
    @FXML private Text tAbout;
    @FXML private Text tFrom;
    @FXML private Text tHello;
    @FXML private Text tLand;

    @Override
    public void initialize(final URL location, final ResourceBundle resources) {
        // Menu
        this.bind(lLanguages.textProperty(), "demo.i18n.languages");        // NOI18N
        this.bind(bFrench.textProperty(),    "demo.i18n.language.french");  // NOI18N
        this.bind(bGerman.textProperty(),    "demo.i18n.language.german");  // NOI18N
        this.bind(bItalian.textProperty(),   "demo.i18n.language.italian"); // NOI18N
        this.bind(bEnglish.textProperty(),   "demo.i18n.language.english"); // NOI18N
        
        // Message
        this.bind(tHello.textProperty(), "demo.i18n.greetings"); // NOI18N
        this.bind(tFrom.textProperty(),  "demo.i18n.from");      // NOI18N
        this.bind(tLand.textProperty(),  "demo.i18n.land");      // NOI18N
        this.bind(tAbout.textProperty(), "demo.i18n.about");     // NOI18N
    }

    private void bind(final StringProperty stringProperty, final String key) {
        final Optional<StringBinding> optionalStringBinding = I18NBindingBuilder.bind().key(key).build();
        optionalStringBinding.ifPresent(stringBinding -> {
            stringProperty.bind(stringBinding);
        });
    }
}

Step four: Switch the language during runtime

And in the last step the user will change the language in the runing application which leads to a change from the actualLocale which performs then the language update in the gui.

public final class DemoI18NController extends FXMLController implements Initializable {

    public void onActionSwitchToLanguageFrench() {
        if (I18NFacade.getDefault().getActualLocale().equals(Locale.FRENCH)) {
            LoggerFacade.getDefault().debug(this.getClass(), "Shows already the Locale.FRENCH - do nothing."); // NOI18N
            return;
        }
        
        I18NFacade.getDefault().setActualLocale(Locale.FRENCH);
    }
    
    public void onActionSwitchToLanguageGerman() {
        if (I18NFacade.getDefault().getActualLocale().equals(Locale.GERMAN)) {
            LoggerFacade.getDefault().debug(this.getClass(), "Shows already the Locale.GERMAN - do nothing."); // NOI18N
            return;
        }
        
        I18NFacade.getDefault().setActualLocale(Locale.GERMAN);
    }
    
    ...
}

JavaDoc

The JavaDoc from the library Lib-I18N can be explored here: JavaDoc Lib-I18N

Image: JavaDoc Lib-I18N v0.7.2
Lib-I18N_JavaDoc_v0.7.2_2019-04-22_09-39.png

Download

Current version is 0.8.0. Main points in this release are:

  • Implement a demo which shows how easy an application becomes multilingual in four steps 😄 .
  • Extend the ReadMe with a new chapter 'Demo'.
  • Fix some minor bugs (test files are copied into the .jar file).

Maven coordinates
In context from a Maven project you can use following maven coordinates:

<dependencies>
    <dependency>
        <groupId>com.github.naoghuman</groupId>
        <artifactId>lib-i18n</artifactId>
        <version>0.8.0</version>
    </dependency>

    <!-- optional -->
    <dependency>
        <groupId>com.github.naoghuman</groupId>
        <artifactId>lib-logger</artifactId>
        <version>0.6.0</version>
    </dependency>
</dependencies>

Download:

An overview about all existings releases can be found here:

  • Overview from all releases in Lib-I18N.

Requirements

In the library are following libraries registered as dependencies:

Installation

Install the project in your preferred IDE

Contribution

  • If you find a Bug I will be glad if you could report an Issue.
  • If you want to contribute to the project plz fork the project and do a Pull Request.

License

The project Lib-I18n is licensed under General Public License 3.0.

Autor

The project Lib-I18n is maintained by me, Peter Rogge. See Contact.

Contact

You can reach me under [email protected].

About

The library `Lib-I18N` allows a developer to bind a key-value pair of a `.properties` file to a [StringBinding]. This makes it very easy to change the language during runtime in a [JavaFX] application.

Topics

Resources

License

Stars

Watchers

Forks

Packages

No packages published