CurrencyConverter.kt

/*
Copyright 2016 Hermann Krumrey <hermann@krumreyh.com>

This file is part of papio.

papio is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.

papio is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
GNU General Public License for more details.

You should have received a copy of the GNU General Public License
along with papio.  If not, see <http://www.gnu.org/licenses/>.
*/

package net.namibsun.papio.lib.money

import net.namibsun.papio.lib.exceptions.CurrencyConversionError
import java.io.File
import java.io.FileInputStream
import java.io.FileOutputStream
import java.io.IOException
import java.io.ObjectInputStream
import java.io.ObjectOutputStream
import java.math.BigDecimal
import java.math.RoundingMode
import java.net.URL
import java.util.Scanner
import java.util.logging.Logger

/**
 * Singleton object that handles the exchanging of monetary values from one currency to another.
 */
object CurrencyConverter {

    /**
     * A logger for this class
     */
    private val logger = Logger.getLogger(CurrencyConverter::class.java.name)

    /**
     * Can be set to true to disable any network operations.
     * Originally added to enable testing the caching functionality.
     */
    var networkDisabled = false

    /**
     * The current exchange rates with Euro as the base currency
     */
    private var exchangeRates = mutableMapOf(Currency.EUR to BigDecimal("1.0"))

    /**
     * A file that can be used to store cached exchange rate values
     */
    private var cacheFile: File? = null

    /**
     * A cache storing exchange rate data. Can be set by using the setCache() method.
     * Will only be used as fallback in case a currency exchange rate could not be found.
     * Useful for offline modes.
     * The generateCache() method overwrites this variable with the current value represented
     * in the exchangeRates variable.
     */
    private var cache = mutableMapOf(Currency.EUR to BigDecimal("1.0"))

    /**
     * A UNIX timestamp that keeps track of when the exchange rates were updated last
     */
    private var updated: Long = 0

    /**
     * Flag that is set to true while updating
     */
    private var updating: Boolean = true

    /**
     * Flag that is set to true once an update has completed
     */
    private var valid: Boolean = false

    /**
     * @return The internal exchange rate data map
     */
    fun getExchangeRateData(): MutableMap<Currency, BigDecimal> {
        return this.exchangeRates
    }

    /**
     * Loads the cache data from the cache file
     */
    private fun loadCache() {
        if (this.cacheFile != null && this.cacheFile!!.exists()) {
            val file = FileInputStream(this.cacheFile)
            val obj = ObjectInputStream(file)
            @Suppress("UNCHECKED_CAST")
            this.cache = obj.readObject() as MutableMap<Currency, BigDecimal>
        } else {
            this.cache = mutableMapOf(Currency.EUR to BigDecimal("1.0"))
        }
    }

    /**
     * Store the current cache data in the cache file
     */
    private fun storeCache() {

        if (this.cacheFile != null) {
            val file = FileOutputStream(this.cacheFile)
            val obj = ObjectOutputStream(file)
            obj.writeObject(this.cache)
            file.close()
            obj.close()
        }
    }

    /**
     * Sets the cache file. This file will be used to store cached exchange rates
     * @param cacheFile: The file in which to store the cached data
     */
    fun setCacheFile(cacheFile: File?) {
        this.cacheFile = cacheFile
        this.loadCache()
    }

    /**
     * Sets the cache variable as a fallback for currency exchange rates that could not be found
     * @param cache: The cache to set
     */
    fun setCache(cache: MutableMap<Currency, BigDecimal>) {
        this.cache = cache
    }

    /**
     * Sets the cache to the current exchange rates and return that value.
     * @return The new cache value
     */
    fun generateCache(): Map<Currency, BigDecimal> {
        this.cache = this.exchangeRates
        return this.cache
    }

    /**
     * Checks if the values are currently valid
     * @return true if the values are valid. Else, return false.
     */
    fun isValid(): Boolean {
        // First, Check if all currencies are in the exchange rate data
        // The make sure that no update is running currently and the update() method deemed the stat valid
        return if (Currency.values().any { it !in this.exchangeRates }) false else this.valid && !this.updating
    }

    /**
     * Updates the exchange rate data. Will only update if more than a minute has passed since
     * the last update or if the force variable is set to true.
     * @param force: Forces an update if set to true
     * @param reset: Resets the internal exchange rate data before updating
     */
    fun update(force: Boolean = false, reset: Boolean = false) {
        this.updating = true
        this.valid = true // Might be set to false by the individual update* methods

        if (reset) {
            this.exchangeRates = mutableMapOf(Currency.EUR to BigDecimal("1.0"))
        }

        if (force or ((System.currentTimeMillis() - this.updated) > 60000)) {
            this.updated = System.currentTimeMillis()
            this.updateFiatCurrencyExchangeRates()
            this.updateCryptoCurrencyExchangeRates()
        } else {
            this.logger.fine("Skipping exchange rate update")
        }
        this.cache = this.exchangeRates
        this.storeCache()
        this.updating = false
    }

    /**
     * Updates the exchange rate data for Fiat currencies
     */
    private fun updateFiatCurrencyExchangeRates() {

        val url = "http://www.ecb.europa.eu/stats/eurofxref/eurofxref-daily.xml"
        val exchangeRateData = this.getUrlData(url)

        for (currency in Currency.getAllFiatCurrencies()) {

            // Format example: <Cube currency='USD' rate='1.1952'/>
            try {
                val splitter = if (currency == Currency.NAD) {
                    Currency.ZAR.name
                } else {
                    currency.name
                }

                var rate = exchangeRateData.split("$splitter' rate='")[1]
                rate = rate.split("'")[0]
                this.exchangeRates[currency] = BigDecimal(rate)
            } catch (e: IndexOutOfBoundsException) {
                this.handleMissingExchangeRateData(currency)
            } catch (e: NumberFormatException) {
                this.handleMissingExchangeRateData(currency)
            }
        }
        this.exchangeRates[Currency.EUR] = BigDecimal("1.0") // Make sure Euro is set to 1.0
    }

    /**
     * Updates the exchange rate data for crypto-currencies
     */
    private fun updateCryptoCurrencyExchangeRates() {

        val one = BigDecimal("1.0")

        val url = "https://api.coinmarketcap.com/v1/ticker/?convert=EUR"
        val melonUrl = "https://api.coinmarketcap.com/v1/ticker/melon/?convert=EUR" // Add Melon through own url
        val exchangeRateData = this.getUrlData(url) + this.getUrlData(melonUrl)
        for (currency in Currency.getAllCryptoCurrencies()) {

            try {
                var price = exchangeRateData.split("\"symbol\": \"${currency.name}\"")[1]
                price = price.split("\"price_eur\": \"")[1].split("\"")[0]
                this.exchangeRates[currency] = one.divide(BigDecimal(price), 128, RoundingMode.HALF_UP)
            } catch (e: IndexOutOfBoundsException) {
                this.handleMissingExchangeRateData(currency)
            } catch (e: NumberFormatException) {
                this.handleMissingExchangeRateData(currency)
            }
        }
    }

    /**
     * Handles supplying exchange rate data if fetching them from the internet did not work
     * @param currency: The currency for which to use the cached value
     */
    private fun handleMissingExchangeRateData(currency: Currency) {

        when (currency) {
            Currency.EUR -> { }
            in this.cache -> {
                this.logger.info("Using cached value for $currency.")
                this.exchangeRates[currency] = this.cache[currency]!!
            }
            else -> {
                this.logger.warning("No valid exchange rate data for $currency.")
            }
        }
    }

    /**
     * Puts in a GET request to a URL
     * @param url: The URL to which to send the GET request
     * @return The response body of the GET request
     */
    private fun getUrlData(url: String): String {

        if (this.networkDisabled) {
            return ""
        }

        return try {
            val connection = URL(url).openConnection()
            val inputStream = connection.getInputStream()
            val scanner = Scanner(inputStream).useDelimiter("\\A")

            var result = ""
            while (scanner.hasNext()) {
                result += scanner.next()
            }
            result
        } catch (e: IOException) {
            ""
        }
    }

    /**
     * Converts a value from one currency to another.
     * @param value: The value to convert
     * @param source: The source currency from which to convert from
     * @param destination: The destination currency into which to convert to
     * @return The converted value as a BigDecimal
     */
    fun convertValue(value: BigDecimal, source: Currency, destination: Currency): BigDecimal {

        this.update()

        if (!this.isValid()) {
            this.logger.warning("Converting while values are invalid!")
        }

        if (source == destination) {
            return value
        }

        try {
            val sourceEuroValue = this.exchangeRates[source]!! // Not null
            val destinationEuroValue = this.exchangeRates[destination]!! // Not null
            return value.divide(sourceEuroValue, 128, RoundingMode.HALF_UP).times(destinationEuroValue)
        } catch (e: NullPointerException) {
            throw CurrencyConversionError(source, destination)
        }
    }
}