fr.kwiatkowski.apktrack.service.WebService.java Source code

Java tutorial

Introduction

Here is the source code for fr.kwiatkowski.apktrack.service.WebService.java

Source

/*
 * Copyright (c) 2015
 *
 * ApkTrack 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.
 *
 * ApkTrack 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 ApkTrack.  If not, see <http://www.gnu.org/licenses/>.
 */

package fr.kwiatkowski.apktrack.service;

import android.app.*;
import android.content.Context;
import android.content.Intent;
import android.content.SharedPreferences;
import android.content.res.Resources;
import android.net.ConnectivityManager;
import android.net.NetworkInfo;
import android.net.Uri;
import android.os.Environment;
import android.preference.PreferenceManager;
import android.support.v4.app.NotificationCompat;
import android.util.Log;
import android.util.Pair;
import de.greenrobot.event.EventBus;
import de.greenrobot.event.NoSubscriberEvent;
import fr.kwiatkowski.apktrack.MainActivity;
import fr.kwiatkowski.apktrack.R;
import fr.kwiatkowski.apktrack.model.InstalledApp;
import fr.kwiatkowski.apktrack.model.UpdateSource;
import fr.kwiatkowski.apktrack.model.UpdateSourceEntry;
import fr.kwiatkowski.apktrack.service.message.CreateToastMessage;
import fr.kwiatkowski.apktrack.service.message.ModelModifiedMessage;
import fr.kwiatkowski.apktrack.service.message.StickyUpdatedMessage;
import fr.kwiatkowski.apktrack.service.utils.SSLHelper;
import fr.kwiatkowski.apktrack.ui.AppDisplayFragment;
import fr.kwiatkowski.apktrack.ui.SettingsFragment;

import javax.net.ssl.HttpsURLConnection;
import javax.net.ssl.SSLHandshakeException;
import java.io.*;
import java.net.*;
import java.util.ArrayList;
import java.util.Date;
import java.util.List;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

/**
 * This service performs web requests to obtain the latest version of a given app.
 * An IntentService is used instead of relying on EventBus, because it allows
 * intents to be bufferred. The "check updates for all apps" button would cause
 * the creation of many simultaneous threads with EventBus.
 */
public class WebService extends IntentService {
    public static final String TARGET_APP_PARAMETER = "target_app";
    public static final String SOURCE_PARAMETER = "source";
    public static final String ACTION = "action";
    public static final String ACTION_VERSION_CHECK = "version_check";
    public static final String ACTION_DOWNLOAD_APK = "download_apk";

    private static final String _nexus_5_user_agent = "Mozilla/5.0 (Linux; Android 4.4; Nexus 5 Build/BuildID) AppleWebKit/537.36"
            + " (KHTML, like Gecko) Version/4.0 Chrome/30.0.0.0 Mobile Safari/537.36";
    private static Pattern _fdroid_not_found;
    private static Pattern _appbrain_not_found;
    private static Pattern _play_store_not_a_version;
    static {
        _fdroid_not_found = Pattern.compile("<p>Application not found</p>");
        _appbrain_not_found = Pattern.compile("Got a 200 OK, but nothing matched by the regex.");
        _play_store_not_a_version = Pattern.compile("^([^ ]| \\()*$");
    }

    // --------------------------------------------------------------------------------------------

    public WebService() {
        super("WebService");
        EventBus.getDefault().register(this);
    }

    // --------------------------------------------------------------------------------------------

    @Override
    protected void onHandleIntent(Intent intent) {
        String package_name = intent.getStringExtra(TARGET_APP_PARAMETER);
        String request_source = intent.getStringExtra(SOURCE_PARAMETER);
        String action = intent.getStringExtra(ACTION);

        InstalledApp app = InstalledApp.find_app(package_name);
        if (!_check_arguments(app, request_source, action)) {
            return;
        }

        if (action.equals(ACTION_VERSION_CHECK)) {
            _perform_version_check(app, request_source);
        } else if (action.equals(ACTION_DOWNLOAD_APK)) {
            _download_apk(app, request_source);
        }
    }

    // --------------------------------------------------------------------------------------------

    /**
     * Performs a web request to obtain the contents of the source's page related to the app.
     * @param source The <code>UpdateSource</code> used by the app. It contains the template of
     *               the URL to query.
     * @param app The app for which the page should be requested.
     * @return An object which represents the contents of the web request, and contains the raw
     * HTML data in case of success.
     */
    private GetResult get_page(UpdateSource source, InstalledApp app) {
        String url = String.format(source.get_url(), app.get_package_name());

        Log.v(MainActivity.TAG, "Requesting " + url);
        InputStream conn = null;
        try {
            URL target = new URL(url);
            HttpURLConnection huc = (HttpURLConnection) target.openConnection();

            // Authenticate ApkTrack's servers against the bundled certificate.
            if ("https".equals(target.getProtocol()) && "apktrack.kwiatkowski.fr".equals(target.getHost())
                    && huc instanceof HttpsURLConnection) {
                ((HttpsURLConnection) huc).setSSLSocketFactory(SSLHelper.get_ssl_socket_factory(WebService.this));
            }

            String user_agent = System.getProperty("http.agent");
            if (user_agent == null) { // Some devices seem to return null here (see issue #8).
                user_agent = _nexus_5_user_agent;
            }
            huc.setRequestProperty("User-Agent", user_agent);

            huc.setRequestMethod("GET");
            huc.setReadTimeout(10000); // Timeout : 10s
            huc.connect();
            conn = huc.getInputStream();
            String page_contents = _read_all(conn, 2048);

            // Fix update source quirks
            _fix_update_source_response(source, page_contents);

            return new GetResult(page_contents);
        } catch (FileNotFoundException e) {
            Log.v(MainActivity.TAG, "404 error while getting " + url + "!");
            return new GetResult(GetResult.status_code.ERROR_404);
        } catch (UnknownHostException e) {
            try {
                Log.v(MainActivity.TAG, "Could not resolve " + new URL(url).getHost() + "!");
            } catch (MalformedURLException ignored) {
            }
            return new GetResult(GetResult.status_code.NETWORK_ERROR);
        } catch (ConnectException e) {
            Log.v(MainActivity.TAG, "Could not connect to the UpdateSource (" + e.getMessage() + ").");
            return new GetResult(GetResult.status_code.NETWORK_ERROR);
        } catch (SocketTimeoutException e) {
            Log.v(MainActivity.TAG, "Could not connect to the UpdateSource (" + e.getMessage() + ").");
            return new GetResult(GetResult.status_code.NETWORK_ERROR);
        } catch (SSLHandshakeException e) {
            Log.w(MainActivity.TAG, "Could not establish a secure connexion.");
            return new GetResult(GetResult.status_code.NETWORK_ERROR);
        } catch (Exception e) {
            Log.e(MainActivity.TAG, url + " could not be retrieved! (" + e.getMessage() + ")", e);
            return new GetResult(e);
        } finally {
            if (conn != null) {
                try {
                    conn.close();
                } catch (IOException ignored) {
                }
            }
        }

    }

    // --------------------------------------------------------------------------------------------

    /**
     * Extracts all the relevant data from a web page.
     * The <code>UpdateSourceEntry</code> object contains a list of regular expressions to apply
     * to the web page contents in order to obtain update information (latest version, APK url,
     * changelog, ...).
     * @param page_contents The contents of the web page to scan.
     * @param use The object containing the regular expressions to apply.
     * @param package_name The name of the app currently being checked.
     * @return An object containing all the version information.
     */
    private static VersionResult apply_regexps(String page_contents, UpdateSourceEntry use, String package_name) {
        VersionResult vr = new VersionResult();

        if (use.get_version_regexp() != null) {
            Matcher m = Pattern.compile(String.format(use.get_version_regexp(), package_name))
                    .matcher(page_contents);
            if (m.find()) {
                vr.set_latest_version(m.group(1));
            } else {
                Log.v(MainActivity.TAG, "The regular expression could not find a version number.");
                Log.d(MainActivity.TAG, page_contents); // Dump the page contents to debug the problem.
            }
        }

        if (use.get_download_url() != null) {
            vr.set_download_url(use.get_download_url());
        } else if (use.get_download_regexp() != null) {
            Matcher m = Pattern.compile(String.format(use.get_download_regexp(), package_name))
                    .matcher(page_contents);
            if (m.find()) {
                vr.set_download_url(m.group(1));
            } else {
                Log.v(MainActivity.TAG, "The regular expression could not find a download URL.");
                Log.d(MainActivity.TAG, page_contents); // Dump the page contents to debug the problem.
            }
        }

        // TODO: Changelog

        Log.v(MainActivity.TAG, String.format("Obtained version: %s - Download URL: %s", vr.get_latest_version(),
                vr.get_download_url()));
        return vr;
    }

    // --------------------------------------------------------------------------------------------

    /**
     * Reads the contents of an InputStream and returns it as a String.
     * @param is The InputStream to read from.
     * @param buffer_size The size of the chunks by which the stream is read.
     * @return The contents of the stream as a String.
     */
    private static String _read_all(final InputStream is, final int buffer_size) {
        final char[] buffer = new char[buffer_size];
        final StringBuilder out = new StringBuilder();
        try {
            final Reader in = new InputStreamReader(is, "UTF-8");
            try {
                for (;;) {
                    int rsz = in.read(buffer, 0, buffer.length);
                    if (rsz < 0)
                        break;
                    out.append(buffer, 0, rsz);
                }
            } finally {
                in.close();
            }
        } catch (IOException ignored) {
        }
        return out.toString();
    }

    // --------------------------------------------------------------------------------------------

    /**
     * Perform a first parsing to normalize Update Source answers.
     * If the response isn't usable as-is, an exception is thown.
     *
     * @param source The source which is currently used.
     * @param page_contents The raw (HTML) data returned by the update source.
     */
    private static void _fix_update_source_response(UpdateSource source, String page_contents)
            throws java.io.IOException {
        Matcher m;
        // F-Droid doesn't return a 404 for applications it doesn't have.
        if ("F-Droid".equals(source.get_name())) {
            m = _fdroid_not_found.matcher(page_contents);
            if (m.find()) {
                throw new FileNotFoundException();
            }
        }
        // Appbrain fails to return an error when it doesn't have a version number for some apps (see issue #74).
        else if ("AppBrain".equals(source.get_name())) {
            m = _appbrain_not_found.matcher(page_contents);
            if (m.find()) {
                throw new FileNotFoundException();
            }
        }
    }

    // --------------------------------------------------------------------------------------------

    /**
     * This method is called when a MessageModifiedEvent is posted on the event bus, but the
     * activity is not around to catch it.
     *
     * A notification is displayed to the user since the UI cannot be updated.
     *
     * @param ignored Never used.
     */
    public void onEvent(StickyUpdatedMessage ignored) {
        ModelModifiedMessage m = EventBus.getDefault().getStickyEvent(ModelModifiedMessage.class);
        List<InstalledApp> updated_apps = new ArrayList<InstalledApp>();
        List<Pair<ModelModifiedMessage.event_type, String>> events = m.access_events(new MessageAccessor());
        if (events.size() == 0) {
            return;
        }

        // Check the last event posted. It may not require a notification to be trigerred.
        Pair<ModelModifiedMessage.event_type, String> last = events.get(events.size() - 1);
        if (last.first != ModelModifiedMessage.event_type.APP_UPDATED) {
            return;
        }
        InstalledApp app = InstalledApp.find_app(last.second);
        if (app == null || !app.is_update_available() || app.has_notified()) {
            return;
        }

        // Build the list of apps which will be mentioned in the notification.
        for (Pair<ModelModifiedMessage.event_type, String> p : events) {
            if (p.first != ModelModifiedMessage.event_type.APP_UPDATED) {
                continue;
            }
            app = InstalledApp.find_app(p.second);
            if (app == null || !app.is_update_available() || app.has_notified()) {
                continue;
            }

            if (!updated_apps.contains(app)) {
                updated_apps.add(app);
            }
        }

        if (updated_apps.size() == 0) {
            return;
        }

        Resources r = getResources();
        NotificationCompat.Builder b = new NotificationCompat.Builder(this);
        // Show a notification for updated apps
        if (updated_apps.size() == 1) {
            app = updated_apps.get(updated_apps.size() - 1);
            b.setContentTitle(r.getString(R.string.app_updated_notification, app.get_display_name()))
                    .setContentText(r.getString(R.string.app_version_available, app.get_latest_version()))
                    .setTicker(r.getString(R.string.app_can_be_updated, app.get_display_name()))
                    .setSmallIcon(R.drawable.ic_menu_refresh).setAutoCancel(true); //TODO: Think about launching the search/download on user click.
        } else {
            b.setContentTitle(r.getString(R.string.apps_updated_notification))
                    .setContentText(r.getString(R.string.apps_updated_notification_summary,
                            updated_apps.get(updated_apps.size() - 1).get_display_name(), updated_apps.size() - 1))
                    .setTicker(r.getString(R.string.apps_updated_notification))
                    .setSmallIcon(R.drawable.ic_menu_refresh).setAutoCancel(true);

            NotificationCompat.InboxStyle style = new NotificationCompat.InboxStyle();
            for (InstalledApp ia : updated_apps) {
                style.addLine(r.getString(R.string.app_version_available_2, ia.get_display_name(), ia.get_version(),
                        ia.get_latest_version()));
            }
            style.setBigContentTitle(r.getString(R.string.apps_updated_notification));
            b.setStyle(style);
        }

        // Open ApkTrack when the notification is clicked.
        Intent i = new Intent();
        i.setClass(this, MainActivity.class);
        PendingIntent pi = PendingIntent.getActivity(this, 0, i, PendingIntent.FLAG_ONE_SHOT);
        b.setContentIntent(pi);

        NotificationManager mgr = (NotificationManager) getSystemService(Service.NOTIFICATION_SERVICE);
        mgr.notify(1, b.build()); // Consolidate notifications.
    }

    /**
     * When the activity is not around to catch the ModelModifiedMessage, this callback is called.
     * In this case, a notification should be displayed.
     *
     * @param event The event thrown by the EventBus.
     */
    public void onEvent(NoSubscriberEvent event) {
        if (event.originalEvent instanceof ModelModifiedMessage) {
            onEvent(new StickyUpdatedMessage());
        }
    }

    // --------------------------------------------------------------------------------------------

    /**
     * Performs the version check.
     * The app's update source is queried (and detected if none is specified), then the regular
     * expressions of the update source are applied and the app is updated with the new information.
     * @param app The app for which a version check has to be performed.
     * @param request_source The source of the request (may be a background check or a user click).
     *                       Depending on this, a toast may be shown to display an error message.
     */
    private void _perform_version_check(InstalledApp app, String request_source) {
        Log.v(MainActivity.TAG, "Launching version check for " + app.get_display_name());

        UpdateSource source = UpdateSource.get_source(app);
        if (source == null) {
            Log.v(MainActivity.TAG, "Could not find an update source for ");
            return;
        }

        GetResult gr = get_page(source, app);
        if (gr.get_status() != GetResult.status_code.SUCCESS) {
            // UpdateSource discovery: if no update source was chosen by the user,
            if (gr.get_status() == GetResult.status_code.ERROR_404 && app.get_update_source() == null) {
                source = UpdateSource.get_next_source(app, source);
                while (source != null) {
                    gr = get_page(source, app);
                    if (gr.get_status() == GetResult.status_code.SUCCESS) {
                        break;
                    }
                    source = UpdateSource.get_next_source(app, source);
                }
            }

            // No suitable update source found, or the specified update source has no information.
            if (source == null || gr.get_status() == GetResult.status_code.ERROR_404) {
                app.set_error_message(getResources().getString(R.string.no_data_found));
            }
            // Network errors don't cause the app to give up on checks.
            else if (gr.get_status() != GetResult.status_code.SUCCESS
                    && gr.get_status() != GetResult.status_code.NETWORK_ERROR) {
                app.set_last_check_error(true);
                if (gr.get_exception() != null) {
                    app.set_error_message(gr.get_exception().getLocalizedMessage());
                }
            }

            // Only display a toast if the user requested a check manually.
            else if (gr.get_status() == GetResult.status_code.NETWORK_ERROR
                    && AppDisplayFragment.APP_DISPLAY_FRAGMENT_SOURCE.equals(request_source)) {
                EventBus.getDefault()
                        .post(new CreateToastMessage(getResources().getString(R.string.network_error)));
            }
        }

        if (source != null && gr.get_status() == GetResult.status_code.SUCCESS) { // Page obtained successfully. Extract the relevant information.
            UpdateSourceEntry entry = source.get_entry(app.get_package_name());

            if (entry == null) {
                Log.v(MainActivity.TAG, "No entry or regular expression specified for " + app.get_package_name()
                        + " in " + source.get_name() + "!");
                return;
            }

            // Get the latest version.
            VersionResult vr = apply_regexps(gr.get_page_contents(), entry, app.get_package_name());

            if (vr.get_latest_version() == null) // Regexp did not match anything.
            {
                app.set_error_message(getResources().getString(R.string.regexp_no_match));
            }
            // If we didn't know about this version, save the new information.
            else if (app.get_latest_version() == null
                    || !app.get_latest_version().equals(vr.get_latest_version())) {
                app.set_last_check_error(false);
                app.set_latest_version(vr.get_latest_version());
                if (app.is_update_available()) {
                    app.set_download_url(vr.get_download_url());
                    if (app.get_download_id() != 0) { // A new version is available. If an APK was downloaded,
                        app.clean_downloads(this); // it is now obsolete.
                    }
                } else { // Don't store URLs for current or old APKs.
                    app.set_download_url(null);
                }
            } else // We knew about this version.
            {
                // Update the download URL (in case the APK has moved).
                if (app.get_download_url() == null || !app.get_download_url().equals(vr.get_download_url())) {
                    app.set_download_url(vr.get_download_url());
                }
                app.set_last_check_error(false);
            }

            app.set_last_check_date(new Date());
            // Set as default update source if none existed.
            if (app.get_update_source() == null && !app.is_last_ckeck_error()) {
                app.set_update_source(source.get_name());
            }
        }

        // Hide the spinner
        app.set_currently_checking(false);

        // Automatically download the APK if a new one is available
        if (app.is_update_available() && app.get_download_url() != null
                && ScheduledCheckService.SERVICE_SOURCE.equals(request_source)
                && PreferenceManager.getDefaultSharedPreferences(this)
                        .getBoolean(SettingsFragment.KEY_PREF_DOWNLOAD_APKS, false)) {
            Log.v(MainActivity.TAG, "Automatically downloading the latest APK.");
            _download_apk(app, request_source); // app.save() + notification are done in this function.
        } else {
            // Save updates to the app and notify.
            app.save();
            EventBusHelper.post_sticky(ModelModifiedMessage.event_type.APP_UPDATED, app.get_package_name());
        }

        // Sleep to avoid flooding the update source.
        try {
            if (source != null) {
                Thread.sleep(source.get_request_delay());
            }
        } catch (InterruptedException ignored) {
        }
    }

    // --------------------------------------------------------------------------------------------

    /**
     * Downloads the APK for a given app if a download URL is available.
     * APK downloads take place through the Download Service. Files are stored on a dedicated
     * folder on the external storage of the device.
     * @param app The app whose APK is to be downloaded.
     */
    private void _download_apk(InstalledApp app, String request_source) {
        if (app.get_download_url() == null) {
            return;
        }

        Uri uri = Uri
                .parse(String.format(app.get_download_url(), app.get_display_name(), app.get_latest_version()));

        DownloadManager dm = (DownloadManager) getSystemService(Context.DOWNLOAD_SERVICE);
        DownloadManager.Request request = new DownloadManager.Request(uri);

        // Indicate whether the download may take place over mobile data.
        // Download over data is okay on user click, but respect the preference for background checks.
        if (ScheduledCheckService.SERVICE_SOURCE.equals(request_source) && PreferenceManager
                .getDefaultSharedPreferences(this).getBoolean(SettingsFragment.KEY_PREF_WIFI_ONLY, true)) {
            request.setAllowedNetworkTypes(DownloadManager.Request.NETWORK_WIFI);
        } else {
            request.setAllowedNetworkTypes(
                    DownloadManager.Request.NETWORK_MOBILE | DownloadManager.Request.NETWORK_WIFI);
        }

        // Don't download APKs when roaming.
        request.setAllowedOverRoaming(false).setTitle(getString(getApplicationInfo().labelRes))
                .setDescription(app.get_display_name() + " " + app.get_latest_version() + ".apk")
                .setVisibleInDownloadsUi(false).setDestinationInExternalFilesDir(this,
                        Environment.DIRECTORY_DOWNLOADS,
                        app.get_package_name() + "-" + app.get_latest_version() + ".apk");
        long id = dm.enqueue(request);
        app.set_download_id(id);
        app.save();
        EventBusHelper.post_sticky(ModelModifiedMessage.event_type.APP_UPDATED, app.get_package_name());
    }

    // --------------------------------------------------------------------------------------------

    /**
     * Checks whether the intent arguments are valid and whether the version check should proceed.
     * @param app The app which is being checked.
     * @param request_source The source of the intent.
     * @return Whether the version check should proceed.
     */
    private boolean _check_arguments(InstalledApp app, String request_source, String action) {
        if (app == null || request_source == null || action == null) {
            Log.v(MainActivity.TAG, "WebService was invoked with no targetApp and/or source argument!");
            return false;
        }

        // Unsupported action
        if (!action.equals(ACTION_DOWNLOAD_APK) && !action.equals(ACTION_VERSION_CHECK)) {
            return false;
        }

        if (ScheduledCheckService.SERVICE_SOURCE.equals(request_source) && _check_cancellation()) {
            return false;
        }
        return true;
    }

    // --------------------------------------------------------------------------------------------

    /**
     * Checks whether the check should be aborted.
     * This may happen depending on user preferences (i.e. WiFi only)
     * @return True if the check should be cancelled.
     */
    private boolean _check_cancellation() {
        SharedPreferences pref = PreferenceManager.getDefaultSharedPreferences(this);
        // If the background checks are not allowed, abort.
        if (!pref.getBoolean(SettingsFragment.KEY_PREF_BACKGROUND_CHECKS, false)) {
            return true;
        }

        // If the user requested it, verify that we are using WiFi before each request.
        if (pref.getBoolean(SettingsFragment.KEY_PREF_WIFI_ONLY, true)) {
            ConnectivityManager connectivityManager = (ConnectivityManager) getSystemService(CONNECTIVITY_SERVICE);
            NetworkInfo info = connectivityManager.getActiveNetworkInfo();
            if (info == null) {
                Log.v(MainActivity.TAG, "Aborting automatic checks because no network is currently active.");
                return true;
            } else if (info.getType() != ConnectivityManager.TYPE_WIFI) {
                Log.v(MainActivity.TAG, "Aborting automatic checks over data due to user preferences.");
                return true;
            }
        }
        return false;
    }

    // --------------------------------------------------------------------------------------------
    // Inner class MessageAccessor
    // --------------------------------------------------------------------------------------------

    /**
     * This class is used as an access mechanism to access a method in
     * <code>ModelModifiedMessage</code>. Since it's an inner class, only WebService can
     * create it.
     * @see ModelModifiedMessage#access_events(WebService.MessageAccessor)
     */
    public static class MessageAccessor {
        private MessageAccessor() {
        }
    }
}

// --------------------------------------------------------------------------------------------
// GetResult
// --------------------------------------------------------------------------------------------

/**
 * This class contains the data obtained when a web page is queried.
 */
class GetResult {
    public enum status_code {
        SUCCESS, NETWORK_ERROR, ERROR_404, UNKNOWN_ERROR
    }

    private status_code _status;
    private String _page_contents;
    private Exception _exception = null;

    // --------------------------------------------------------------------------------------------

    public GetResult(String page_contents) {
        _status = status_code.SUCCESS;
        _page_contents = page_contents;
    }

    // --------------------------------------------------------------------------------------------

    public GetResult(status_code status) {
        _status = status;
        _page_contents = null;
    }

    // --------------------------------------------------------------------------------------------

    public GetResult(Exception e) {
        _status = status_code.UNKNOWN_ERROR;
        _exception = e;
    }

    // --------------------------------------------------------------------------------------------

    public status_code get_status() {
        return _status;
    }

    public String get_page_contents() {
        return _page_contents;
    }

    public Exception get_exception() {
        return _exception;
    }
}

// --------------------------------------------------------------------------------------------
// VersionResult
// --------------------------------------------------------------------------------------------

/**
 * This class represents the data returned after regular expressions have been applied to a web page.
 */
class VersionResult {
    private String _latest_version;
    private String _download_url;
    private String _changelog;
    private GetResult.status_code status = GetResult.status_code.SUCCESS;

    public String get_latest_version() {
        return _latest_version;
    }

    public void set_latest_version(String latest_version) {
        this._latest_version = latest_version.trim();
    }

    public String get_download_url() {
        return _download_url;
    }

    public void set_download_url(String download_url) {
        this._download_url = download_url.trim();
    }

    public String get_changelog() {
        return _changelog;
    }

    public void set_changelog(String changelog) {
        this._changelog = changelog;
    }
}