From 5fbce3e31d86f543a4313079a155e1483d511b43 Mon Sep 17 00:00:00 2001 From: Floens Date: Sat, 18 Mar 2017 14:04:37 +0100 Subject: [PATCH] Add update checker and installer. The update checker loads a json file from the github site and checks if there are newer versions available. If there are, allow the user to download and install the update with just a few clicks. --- Clover/app/build.gradle | 35 ++- .../chan/core/net/UpdateApiRequest.java | 162 ++++++++++ .../chan/core/settings/ChanSettings.java | 7 + .../chan/core/settings/LongSetting.java | 49 +++ .../chan/core/update/UpdateManager.java | 238 +++++++++++++++ .../chan/ui/activity/StartActivity.java | 11 +- .../ui/controller/MainSettingsController.java | 9 + .../ui/controller/SaveLocationController.java | 34 +-- .../ui/helper/PreviousVersionHandler.java | 118 -------- .../ui/helper/RuntimePermissionsHelper.java | 36 +++ .../floens/chan/ui/helper/VersionHandler.java | 282 ++++++++++++++++++ .../java/org/floens/chan/utils/IOUtils.java | 25 ++ Clover/app/src/main/res/values/strings.xml | 36 ++- docs/update_api.json | 22 ++ docs/update_api.txt | 29 ++ 15 files changed, 931 insertions(+), 162 deletions(-) create mode 100644 Clover/app/src/main/java/org/floens/chan/core/net/UpdateApiRequest.java create mode 100644 Clover/app/src/main/java/org/floens/chan/core/settings/LongSetting.java create mode 100644 Clover/app/src/main/java/org/floens/chan/core/update/UpdateManager.java delete mode 100644 Clover/app/src/main/java/org/floens/chan/ui/helper/PreviousVersionHandler.java create mode 100644 Clover/app/src/main/java/org/floens/chan/ui/helper/VersionHandler.java create mode 100644 docs/update_api.json create mode 100644 docs/update_api.txt diff --git a/Clover/app/build.gradle b/Clover/app/build.gradle index e8ba993d..9fc01216 100644 --- a/Clover/app/build.gradle +++ b/Clover/app/build.gradle @@ -18,7 +18,6 @@ android { buildToolsVersion '25.0.1' defaultConfig { - applicationId "org.floens.chan" minSdkVersion 15 targetSdkVersion 25 @@ -66,6 +65,32 @@ android { is.close() } + defaultPublishConfig "default" + productFlavors { + // The app name refers to the name as displayed on the launcher. + // the flavor name is appended to the name in the settings. + "default" { + applicationId "org.floens.chan" + resValue "string", "app_name", "Clover" + resValue "string", "app_flavor_name", "" + buildConfigField "String", "UPDATE_API_ENDPOINT", "\"https://floens.github.io/Clover/api/update\"" + } + + dev { + applicationId "org.floens.chan.dev" + resValue "string", "app_name", "Clover dev" + resValue "string", "app_flavor_name", "" + buildConfigField "String", "UPDATE_API_ENDPOINT", "\"\"" + } + + fdroid { + applicationId "org.floens.chan" + resValue "string", "app_name", "Clover" + resValue "string", "app_flavor_name", "F-Droid" + buildConfigField "String", "UPDATE_API_ENDPOINT", "\"https://floens.github.io/Clover/api/update\"" + } + } + buildTypes { release { if (doSign) { @@ -82,14 +107,6 @@ android { } } - productFlavors { - normal { - applicationId = "org.floens.chan" - resValue "string", "app_name", "Clover" - resValue "string", "app_flavor_name", "" - } - } - sourceSets { beta.java.srcDirs = ['src/release/java'] } diff --git a/Clover/app/src/main/java/org/floens/chan/core/net/UpdateApiRequest.java b/Clover/app/src/main/java/org/floens/chan/core/net/UpdateApiRequest.java new file mode 100644 index 00000000..226e5c59 --- /dev/null +++ b/Clover/app/src/main/java/org/floens/chan/core/net/UpdateApiRequest.java @@ -0,0 +1,162 @@ +/* + * Clover - 4chan browser https://github.com/Floens/Clover/ + * Copyright (C) 2014 Floens + * + * This program 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. + * + * This program 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 this program. If not, see . + */ +package org.floens.chan.core.net; + + +import android.util.JsonReader; + +import com.android.volley.Response; + +import org.floens.chan.BuildConfig; + +import java.io.IOException; +import java.text.DateFormat; +import java.text.ParseException; +import java.text.SimpleDateFormat; +import java.util.ArrayList; +import java.util.Date; +import java.util.List; +import java.util.Locale; + +import okhttp3.HttpUrl; + +public class UpdateApiRequest extends JsonReaderRequest { + public static final String TYPE_UPDATE = "update"; + + private static final int API_VERSION = 1; + + private String forFlavor; + + public UpdateApiRequest(Response.Listener listener, Response.ErrorListener errorListener) { + super(BuildConfig.UPDATE_API_ENDPOINT, listener, errorListener); + forFlavor = BuildConfig.FLAVOR; + } + + @Override + public UpdateApiResponse readJson(JsonReader reader) throws Exception { + reader.beginObject(); + + UpdateApiResponse response = new UpdateApiResponse(); + + int apiVersion; + out: + while (reader.hasNext()) { + switch (reader.nextName()) { + case "api_version": + apiVersion = reader.nextInt(); + + if (apiVersion > API_VERSION) { + response.newerApiVersion = true; + + while (reader.hasNext()) reader.skipValue(); + + break out; + } + + break; + case "messages": + reader.beginArray(); + while (reader.hasNext()) { + response.messages.add(readMessage(reader)); + } + reader.endArray(); + break; + case "check_interval": + response.checkIntervalMs = reader.nextLong(); + break; + default: + reader.skipValue(); + break; + } + } + + reader.endObject(); + + return response; + } + + private UpdateApiMessage readMessage(JsonReader reader) throws IOException { + reader.beginObject(); + + UpdateApiMessage message = new UpdateApiMessage(); + + while (reader.hasNext()) { + switch (reader.nextName()) { + case "type": + message.type = reader.nextString(); + break; + case "code": + message.code = reader.nextInt(); + break; + case "date": + DateFormat format = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", Locale.US); + try { + message.date = format.parse(reader.nextString()); + } catch (ParseException ignore) { + } + break; + case "message_html": + message.messageHtml = reader.nextString(); + break; + case "apk": + reader.beginObject(); + while (reader.hasNext()) { + if (reader.nextName().equals(forFlavor)) { + reader.beginObject(); + while (reader.hasNext()) { + switch (reader.nextName()) { + case "url": + message.apkUrl = HttpUrl.parse(reader.nextString()); + break; + default: + reader.skipValue(); + break; + } + } + reader.endObject(); + } else { + reader.skipValue(); + } + } + reader.endObject(); + break; + default: + reader.skipValue(); + break; + } + } + + reader.endObject(); + + return message; + } + + public static class UpdateApiResponse { + public boolean newerApiVersion; + public List messages = new ArrayList<>(); + public long checkIntervalMs; + } + + public static class UpdateApiMessage { + public String type; + public int code; + public Date date; + public String messageHtml; + public HttpUrl apkUrl; + } +} diff --git a/Clover/app/src/main/java/org/floens/chan/core/settings/ChanSettings.java b/Clover/app/src/main/java/org/floens/chan/core/settings/ChanSettings.java index 3a417987..cd3c6792 100644 --- a/Clover/app/src/main/java/org/floens/chan/core/settings/ChanSettings.java +++ b/Clover/app/src/main/java/org/floens/chan/core/settings/ChanSettings.java @@ -24,6 +24,7 @@ import android.text.TextUtils; import org.floens.chan.Chan; import org.floens.chan.R; import org.floens.chan.core.manager.WatchManager; +import org.floens.chan.core.update.UpdateManager; import org.floens.chan.ui.adapter.PostsFilter; import org.floens.chan.utils.AndroidUtils; @@ -159,6 +160,9 @@ public class ChanSettings { public static final CounterSetting replyOpenCounter; public static final CounterSetting threadOpenCounter; + public static final LongSetting updateCheckTime; + public static final LongSetting updateCheckInterval; + public enum TestOptions implements OptionSettingItem { ONE("one"), TWO("two"), @@ -288,6 +292,9 @@ public class ChanSettings { replyOpenCounter = new CounterSetting(p, "counter_reply_open"); threadOpenCounter = new CounterSetting(p, "counter_thread_open"); + updateCheckTime = new LongSetting(p, "update_check_time", 0L); + updateCheckInterval = new LongSetting(p, "update_check_interval", UpdateManager.DEFAULT_UPDATE_CHECK_INTERVAL_MS); + // Old (but possibly still in some users phone) // preference_board_view_mode default "list" // preference_board_editor_filler default false diff --git a/Clover/app/src/main/java/org/floens/chan/core/settings/LongSetting.java b/Clover/app/src/main/java/org/floens/chan/core/settings/LongSetting.java new file mode 100644 index 00000000..c68b88d8 --- /dev/null +++ b/Clover/app/src/main/java/org/floens/chan/core/settings/LongSetting.java @@ -0,0 +1,49 @@ +/* + * Clover - 4chan browser https://github.com/Floens/Clover/ + * Copyright (C) 2014 Floens + * + * This program 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. + * + * This program 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 this program. If not, see . + */ +package org.floens.chan.core.settings; + +import android.content.SharedPreferences; + +public class LongSetting extends Setting { + private boolean hasCached = false; + private Long cached; + + public LongSetting(SharedPreferences sharedPreferences, String key, Long def) { + super(sharedPreferences, key, def); + } + + @Override + public Long get() { + if (hasCached) { + return cached; + } else { + cached = sharedPreferences.getLong(key, def); + hasCached = true; + return cached; + } + } + + @Override + public void set(Long value) { + if (!value.equals(get())) { + sharedPreferences.edit().putLong(key, value).apply(); + cached = value; + onValueChanged(); + } + } +} diff --git a/Clover/app/src/main/java/org/floens/chan/core/update/UpdateManager.java b/Clover/app/src/main/java/org/floens/chan/core/update/UpdateManager.java new file mode 100644 index 00000000..b7f6b3a1 --- /dev/null +++ b/Clover/app/src/main/java/org/floens/chan/core/update/UpdateManager.java @@ -0,0 +1,238 @@ +/* + * Clover - 4chan browser https://github.com/Floens/Clover/ + * Copyright (C) 2014 Floens + * + * This program 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. + * + * This program 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 this program. If not, see . + */ +package org.floens.chan.core.update; + + +import android.content.Intent; +import android.net.Uri; +import android.os.Environment; +import android.os.StrictMode; +import android.text.TextUtils; + +import com.android.volley.Response; +import com.android.volley.VolleyError; + +import org.floens.chan.BuildConfig; +import org.floens.chan.Chan; +import org.floens.chan.core.cache.FileCache; +import org.floens.chan.core.net.UpdateApiRequest; +import org.floens.chan.core.settings.ChanSettings; +import org.floens.chan.utils.IOUtils; +import org.floens.chan.utils.Logger; +import org.floens.chan.utils.Time; + +import java.io.File; +import java.io.IOException; + +import okhttp3.HttpUrl; + +/** + * Calls the update API and downloads and requests installs of APK files. + *

The APK files are downloaded to the public Download directory, and the default APK install + * screen is launched after downloading. + */ +public class UpdateManager { + public static final long DEFAULT_UPDATE_CHECK_INTERVAL_MS = 1000 * 60 * 60 * 24 * 5; // 5 days + + private static final String TAG = "UpdateManager"; + + private static final String DOWNLOAD_FILE = "Clover_update.apk"; + + private UpdateCallback callback; + + public UpdateManager(UpdateCallback callback) { + this.callback = callback; + } + + public boolean isUpdatingAvailable() { + return !TextUtils.isEmpty(BuildConfig.UPDATE_API_ENDPOINT); + } + + public void runUpdateApi(final boolean manual) { + if (!manual) { + long lastUpdateTime = ChanSettings.updateCheckTime.get(); + long interval = ChanSettings.updateCheckInterval.get(); + long now = Time.get(); + long delta = (lastUpdateTime + interval) - now; + if (delta > 0) { + return; + } else { + ChanSettings.updateCheckTime.set(now); + } + } + + Logger.d(TAG, "Calling update API"); + Chan.getVolleyRequestQueue().add(new UpdateApiRequest(new Response.Listener() { + @Override + public void onResponse(UpdateApiRequest.UpdateApiResponse response) { + if (!processUpdateApiResponse(response) && manual) { + callback.onManualCheckNone(); + } + } + }, new Response.ErrorListener() { + @Override + public void onErrorResponse(VolleyError error) { + Logger.e(TAG, "Failed to process API call for updating", error); + + if (manual) { + callback.onManualCheckFailed(); + } + } + })); + } + + private boolean processUpdateApiResponse(UpdateApiRequest.UpdateApiResponse response) { + if (response.newerApiVersion) { + Logger.e(TAG, "API endpoint reports a higher API version than we support, aborting update check."); + + // ignore + return false; + } + + if (response.checkIntervalMs != 0) { + ChanSettings.updateCheckInterval.set(response.checkIntervalMs); + } + + for (UpdateApiRequest.UpdateApiMessage message : response.messages) { + if (processUpdateMessage(message)) { + return true; + } + } + + return false; + } + + private boolean processUpdateMessage(UpdateApiRequest.UpdateApiMessage message) { + if (message.code <= BuildConfig.VERSION_CODE) { + Logger.d(TAG, "No newer version available (" + BuildConfig.VERSION_CODE + " >= " + message.code + ")."); + // Our code is newer than the message + return false; + } + + if (message.type.equals(UpdateApiRequest.TYPE_UPDATE)) { + if (message.apkUrl == null) { + Logger.i(TAG, "Update available but none for this build flavor."); + // Not for this flavor, discard. + return false; + } + + Logger.i(TAG, "Update available (" + message.code + ") with url \"" + message.apkUrl + "\"."); + callback.showUpdateAvailableDialog(message); + return true; + } + + return false; + } + + /** + * Install the APK file specified in {@code update}. This methods needs the storage permission. + * + * @param update update with apk details. + */ + public void doUpdate(Update update) { + Chan.getFileCache().downloadFile(update.apkUrl.toString(), new FileCache.DownloadedCallback() { + @Override + public void onProgress(long downloaded, long total, boolean done) { + if (!done) callback.onUpdateDownloadProgress(downloaded, total); + } + + @Override + public void onSuccess(File file) { + callback.onUpdateDownloadSuccess(); + copyToPublicDirectory(file); + } + + @Override + public void onFail(boolean notFound) { + callback.onUpdateDownloadFailed(); + } + }); + } + + public void retry(Install install) { + installApk(install); + } + + private void copyToPublicDirectory(File cacheFile) { + File out = new File(Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS), DOWNLOAD_FILE); + try { + IOUtils.copyFile(cacheFile, out); + } catch (IOException e) { + Logger.e(TAG, "requestApkInstall", e); + callback.onUpdateDownloadMoveFailed(); + return; + } + installApk(new Install(out)); + } + + private void installApk(Install install) { + // First open the dialog that asks to retry and calls this method again. + callback.openUpdateRetryDialog(install); + + // Then launch the APK install intent. + Intent intent = new Intent(Intent.ACTION_VIEW); + intent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP); + intent.setDataAndType(Uri.fromFile(install.installFile), "application/vnd.android.package-archive"); + + // The installer wants a content scheme from android N and up, + // but I don't feel like implementing a content provider just for this feature. + // Temporary change the strictmode policy while starting the intent. + StrictMode.VmPolicy vmPolicy = StrictMode.getVmPolicy(); + StrictMode.setVmPolicy(StrictMode.VmPolicy.LAX); + + callback.onUpdateOpenInstallScreen(intent); + + StrictMode.setVmPolicy(vmPolicy); + } + + public static class Update { + private HttpUrl apkUrl; + + public Update(HttpUrl apkUrl) { + this.apkUrl = apkUrl; + } + } + + public static class Install { + private File installFile; + + public Install(File installFile) { + this.installFile = installFile; + } + } + + public interface UpdateCallback { + void onManualCheckNone(); + + void onManualCheckFailed(); + + void showUpdateAvailableDialog(UpdateApiRequest.UpdateApiMessage message); + + void onUpdateDownloadProgress(long downloaded, long total); + + void onUpdateDownloadSuccess(); + + void onUpdateDownloadFailed(); + + void onUpdateDownloadMoveFailed(); + + void onUpdateOpenInstallScreen(Intent intent); + + void openUpdateRetryDialog(Install install); + } +} diff --git a/Clover/app/src/main/java/org/floens/chan/ui/activity/StartActivity.java b/Clover/app/src/main/java/org/floens/chan/ui/activity/StartActivity.java index 07e36a7c..2f69524a 100644 --- a/Clover/app/src/main/java/org/floens/chan/ui/activity/StartActivity.java +++ b/Clover/app/src/main/java/org/floens/chan/ui/activity/StartActivity.java @@ -51,7 +51,7 @@ import org.floens.chan.ui.controller.StyledToolbarNavigationController; import org.floens.chan.ui.controller.ThreadSlideController; import org.floens.chan.ui.controller.ViewThreadController; import org.floens.chan.ui.helper.ImagePickDelegate; -import org.floens.chan.ui.helper.PreviousVersionHandler; +import org.floens.chan.ui.helper.VersionHandler; import org.floens.chan.ui.helper.RuntimePermissionsHelper; import org.floens.chan.ui.state.ChanState; import org.floens.chan.ui.theme.ThemeHelper; @@ -76,6 +76,7 @@ public class StartActivity extends AppCompatActivity implements NfcAdapter.Creat private ImagePickDelegate imagePickDelegate; private RuntimePermissionsHelper runtimePermissionsHelper; + private VersionHandler versionHandler; public StartActivity() { boardManager = Chan.getBoardManager(); @@ -89,6 +90,7 @@ public class StartActivity extends AppCompatActivity implements NfcAdapter.Creat imagePickDelegate = new ImagePickDelegate(this); runtimePermissionsHelper = new RuntimePermissionsHelper(this); + versionHandler = new VersionHandler(this, runtimePermissionsHelper); contentView = (ViewGroup) findViewById(android.R.id.content); @@ -160,8 +162,7 @@ public class StartActivity extends AppCompatActivity implements NfcAdapter.Creat browseController.loadBoard(boardManager.getSavedBoards().get(0)); } - PreviousVersionHandler previousVersionHandler = new PreviousVersionHandler(); - previousVersionHandler.run(this); + versionHandler.run(); } private void setupLayout() { @@ -330,6 +331,10 @@ public class StartActivity extends AppCompatActivity implements NfcAdapter.Creat return imagePickDelegate; } + public VersionHandler getVersionHandler() { + return versionHandler; + } + public RuntimePermissionsHelper getRuntimePermissionsHelper() { return runtimePermissionsHelper; } diff --git a/Clover/app/src/main/java/org/floens/chan/ui/controller/MainSettingsController.java b/Clover/app/src/main/java/org/floens/chan/ui/controller/MainSettingsController.java index 1b1be17e..bbc01234 100644 --- a/Clover/app/src/main/java/org/floens/chan/ui/controller/MainSettingsController.java +++ b/Clover/app/src/main/java/org/floens/chan/ui/controller/MainSettingsController.java @@ -360,6 +360,15 @@ public class MainSettingsController extends SettingsController implements Toolba } })); + if (((StartActivity) context).getVersionHandler().isUpdatingAvailable()) { + about.add(new LinkSettingView(this, R.string.settings_update_check, 0, new View.OnClickListener() { + @Override + public void onClick(View v) { + ((StartActivity) context).getVersionHandler().manualUpdateCheck(); + } + })); + } + int extraAbouts = context.getResources().getIdentifier("extra_abouts", "array", context.getPackageName()); if (extraAbouts != 0) { String[] abouts = context.getResources().getStringArray(extraAbouts); diff --git a/Clover/app/src/main/java/org/floens/chan/ui/controller/SaveLocationController.java b/Clover/app/src/main/java/org/floens/chan/ui/controller/SaveLocationController.java index 858165b5..a859ebae 100644 --- a/Clover/app/src/main/java/org/floens/chan/ui/controller/SaveLocationController.java +++ b/Clover/app/src/main/java/org/floens/chan/ui/controller/SaveLocationController.java @@ -19,12 +19,7 @@ package org.floens.chan.ui.controller; import android.Manifest; import android.content.Context; -import android.content.DialogInterface; -import android.content.Intent; -import android.net.Uri; -import android.provider.Settings; import android.support.design.widget.FloatingActionButton; -import android.support.v7.app.AlertDialog; import android.view.View; import org.floens.chan.R; @@ -37,10 +32,12 @@ import org.floens.chan.ui.activity.StartActivity; import org.floens.chan.ui.adapter.FilesAdapter; import org.floens.chan.ui.helper.RuntimePermissionsHelper; import org.floens.chan.ui.layout.FilesLayout; -import org.floens.chan.utils.AndroidUtils; import java.io.File; +import static org.floens.chan.R.string.save_location_storage_permission_required; +import static org.floens.chan.R.string.save_location_storage_permission_required_title; + public class SaveLocationController extends Controller implements FileWatcher.FileWatcherCallback, FilesAdapter.Callback, FilesLayout.Callback, View.OnClickListener { private static final String TAG = "SaveLocationController"; @@ -121,26 +118,17 @@ public class SaveLocationController extends Controller implements FileWatcher.Fi if (gotPermission) { initialize(); } else { - new AlertDialog.Builder(context) - .setTitle(R.string.write_permission_required_title) - .setMessage(R.string.write_permission_required) - .setCancelable(false) - .setNeutralButton(R.string.write_permission_app_settings, new DialogInterface.OnClickListener() { - @Override - public void onClick(DialogInterface dialog, int which) { - requestPermission(); - Intent intent = new Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, - Uri.parse("package:" + context.getPackageName())); - AndroidUtils.openIntent(intent); - } - }) - .setPositiveButton(R.string.write_permission_grant, new DialogInterface.OnClickListener() { + runtimePermissionsHelper.showPermissionRequiredDialog( + context, + context.getString(save_location_storage_permission_required_title), + context.getString(save_location_storage_permission_required), + new RuntimePermissionsHelper.PermissionRequiredDialogCallback() { @Override - public void onClick(DialogInterface dialog, int which) { + public void retryPermissionRequest() { requestPermission(); } - }) - .show(); + } + ); } } }); diff --git a/Clover/app/src/main/java/org/floens/chan/ui/helper/PreviousVersionHandler.java b/Clover/app/src/main/java/org/floens/chan/ui/helper/PreviousVersionHandler.java deleted file mode 100644 index 4a949e87..00000000 --- a/Clover/app/src/main/java/org/floens/chan/ui/helper/PreviousVersionHandler.java +++ /dev/null @@ -1,118 +0,0 @@ -/* - * Clover - 4chan browser https://github.com/Floens/Clover/ - * Copyright (C) 2014 Floens - * - * This program 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. - * - * This program 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 this program. If not, see . - */ -package org.floens.chan.ui.helper; - -import android.content.Context; -import android.content.DialogInterface; -import android.support.v7.app.AlertDialog; -import android.text.Html; -import android.widget.Button; - -import org.floens.chan.R; -import org.floens.chan.core.settings.ChanSettings; -import org.floens.chan.utils.AndroidUtils; -import org.floens.chan.utils.Logger; - -import java.io.File; - -public class PreviousVersionHandler { - private static final String TAG = "PreviousVersionHandler"; - - /* - * Manifest version code, manifest version name, this version mapping: - * - * 28 = v1.1.2 - * 32 = v1.1.3 - * 36 = v1.2.0 - * 39 = v1.2.1 - * 40 = v1.2.2 - * 41 = v1.2.3 - * 42 = v1.2.4 - * 43 = v1.2.5 - * 44 = v1.2.6 - * 46 = v1.2.7 - * 47 = v1.2.8 - * 48 = v1.2.9 - * 49 = v1.2.10 - * 50 = v1.2.11 - * 51 = v2.0.0 = 1 - * 52 = v2.1.0 = 2 - * 53 = v2.1.1 = 2 - * 54 = v2.1.2 = 2 - * 55 = v2.1.3 = 2 - * 56 = v2.2.0 = 3 - */ - private static final int CURRENT_VERSION = 3; - - public void run(Context context) { - int previous = ChanSettings.previousVersion.get(); - if (previous < CURRENT_VERSION) { - if (previous < 1) { - cleanupOutdatedIonFolder(context); - } - - // Add more previous version checks here - - showMessage(context, CURRENT_VERSION); - - ChanSettings.previousVersion.set(CURRENT_VERSION); - } - } - - private void showMessage(Context context, int version) { - int resource = context.getResources().getIdentifier("previous_version_" + version, "string", context.getPackageName()); - if (resource != 0) { - CharSequence message = Html.fromHtml(context.getString(resource)); - - final AlertDialog dialog = new AlertDialog.Builder(context) - .setMessage(message) - .setPositiveButton(R.string.ok, null) - .create(); - dialog.show(); - dialog.setCanceledOnTouchOutside(false); - - final Button button = dialog.getButton(DialogInterface.BUTTON_POSITIVE); - button.setEnabled(false); - AndroidUtils.runOnUiThread(new Runnable() { - @Override - public void run() { - dialog.setCanceledOnTouchOutside(true); - button.setEnabled(true); - } - }, 1500); - } - } - - private void cleanupOutdatedIonFolder(Context context) { - Logger.i(TAG, "Cleaning up old ion folder"); - File ionCacheFolder = new File(context.getCacheDir() + "/ion"); - if (ionCacheFolder.exists() && ionCacheFolder.isDirectory()) { - Logger.i(TAG, "Clearing old ion folder"); - for (File file : ionCacheFolder.listFiles()) { - if (!file.delete()) { - Logger.i(TAG, "Could not delete old ion file " + file.getName()); - } - } - if (!ionCacheFolder.delete()) { - Logger.i(TAG, "Could not delete old ion folder"); - } else { - Logger.i(TAG, "Deleted old ion folder"); - } - } - } -} diff --git a/Clover/app/src/main/java/org/floens/chan/ui/helper/RuntimePermissionsHelper.java b/Clover/app/src/main/java/org/floens/chan/ui/helper/RuntimePermissionsHelper.java index da71210d..c665c250 100644 --- a/Clover/app/src/main/java/org/floens/chan/ui/helper/RuntimePermissionsHelper.java +++ b/Clover/app/src/main/java/org/floens/chan/ui/helper/RuntimePermissionsHelper.java @@ -18,10 +18,19 @@ package org.floens.chan.ui.helper; import android.app.Activity; +import android.content.Context; +import android.content.DialogInterface; +import android.content.Intent; import android.content.pm.PackageManager; +import android.net.Uri; +import android.provider.Settings; import android.support.annotation.NonNull; import android.support.v4.app.ActivityCompat; import android.support.v4.content.ContextCompat; +import android.support.v7.app.AlertDialog; + +import org.floens.chan.R; +import org.floens.chan.utils.AndroidUtils; import static org.floens.chan.utils.AndroidUtils.getAppContext; @@ -71,6 +80,33 @@ public class RuntimePermissionsHelper { } } + public void showPermissionRequiredDialog(final Context context, String title, String message, final PermissionRequiredDialogCallback callback) { + new AlertDialog.Builder(context) + .setTitle(title) + .setMessage(message) + .setCancelable(false) + .setNeutralButton(R.string.permission_app_settings, new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + callback.retryPermissionRequest(); + Intent intent = new Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS, + Uri.parse("package:" + context.getPackageName())); + AndroidUtils.openIntent(intent); + } + }) + .setPositiveButton(R.string.permission_grant, new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + callback.retryPermissionRequest(); + } + }) + .show(); + } + + public interface PermissionRequiredDialogCallback { + void retryPermissionRequest(); + } + private class CallbackHolder { private Callback callback; private String permission; diff --git a/Clover/app/src/main/java/org/floens/chan/ui/helper/VersionHandler.java b/Clover/app/src/main/java/org/floens/chan/ui/helper/VersionHandler.java new file mode 100644 index 00000000..75f04477 --- /dev/null +++ b/Clover/app/src/main/java/org/floens/chan/ui/helper/VersionHandler.java @@ -0,0 +1,282 @@ +/* + * Clover - 4chan browser https://github.com/Floens/Clover/ + * Copyright (C) 2014 Floens + * + * This program 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. + * + * This program 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 this program. If not, see . + */ +package org.floens.chan.ui.helper; + +import android.Manifest; +import android.app.ProgressDialog; +import android.content.Context; +import android.content.DialogInterface; +import android.content.Intent; +import android.support.v7.app.AlertDialog; +import android.text.Html; +import android.text.Spanned; +import android.widget.Button; + +import org.floens.chan.R; +import org.floens.chan.core.net.UpdateApiRequest; +import org.floens.chan.core.settings.ChanSettings; +import org.floens.chan.core.update.UpdateManager; +import org.floens.chan.utils.AndroidUtils; +import org.floens.chan.utils.Logger; + +import java.io.File; + +public class VersionHandler implements UpdateManager.UpdateCallback { + private static final String TAG = "VersionHandler"; + + /* + * Manifest version code, manifest version name, this version mapping: + * + * 28 = v1.1.2 + * 32 = v1.1.3 + * 36 = v1.2.0 + * 39 = v1.2.1 + * 40 = v1.2.2 + * 41 = v1.2.3 + * 42 = v1.2.4 + * 43 = v1.2.5 + * 44 = v1.2.6 + * 46 = v1.2.7 + * 47 = v1.2.8 + * 48 = v1.2.9 + * 49 = v1.2.10 + * 50 = v1.2.11 + * 51 = v2.0.0 = 1 + * 52 = v2.1.0 = 2 + * 53 = v2.1.1 = 2 + * 54 = v2.1.2 = 2 + * 55 = v2.1.3 = 2 + * 56 = v2.2.0 = 3 + */ + private static final int CURRENT_VERSION = 3; + + /** + * Context to show dialogs to. + */ + private Context context; + private RuntimePermissionsHelper runtimePermissionsHelper; + + private UpdateManager updateManager; + + private ProgressDialog updateDownloadDialog; + + public VersionHandler(Context context, RuntimePermissionsHelper runtimePermissionsHelper) { + this.context = context; + this.runtimePermissionsHelper = runtimePermissionsHelper; + + updateManager = new UpdateManager(this); + } + + /** + * Runs every time onCreate is called on the StartActivity. + */ + public void run() { + int previous = ChanSettings.previousVersion.get(); + if (previous < CURRENT_VERSION) { + if (previous < 1) { + cleanupOutdatedIonFolder(context); + } + + // Add more previous version checks here + + showMessage(CURRENT_VERSION); + + ChanSettings.previousVersion.set(CURRENT_VERSION); + + // Don't process the updater because a dialog is now already showing. + return; + } + + if (updateManager.isUpdatingAvailable()) { + updateManager.runUpdateApi(false); + } + } + + public boolean isUpdatingAvailable() { + return updateManager.isUpdatingAvailable(); + } + + public void manualUpdateCheck() { + updateManager.runUpdateApi(true); + } + + @Override + public void onManualCheckNone() { + new AlertDialog.Builder(context) + .setTitle(R.string.update_none) + .setPositiveButton(R.string.ok, null) + .show(); + } + + @Override + public void onManualCheckFailed() { + new AlertDialog.Builder(context) + .setTitle(R.string.update_check_failed) + .setPositiveButton(R.string.ok, null) + .show(); + } + + @Override + public void showUpdateAvailableDialog(final UpdateApiRequest.UpdateApiMessage message) { + Spanned text = Html.fromHtml(message.messageHtml); + + final AlertDialog dialog = new AlertDialog.Builder(context) + .setMessage(text) + .setNegativeButton(R.string.update_later, new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + updatePostponed(message); + } + }) + .setPositiveButton(R.string.update_install, new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + updateInstallRequested(message); + } + }) + .create(); + dialog.show(); + dialog.setCanceledOnTouchOutside(false); + } + + private void updatePostponed(UpdateApiRequest.UpdateApiMessage message) { + } + + private void updateInstallRequested(final UpdateApiRequest.UpdateApiMessage message) { + runtimePermissionsHelper.requestPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE, new RuntimePermissionsHelper.Callback() { + @Override + public void onRuntimePermissionResult(boolean granted) { + if (granted) { + createDownloadProgressDialog(); + updateManager.doUpdate(new UpdateManager.Update(message.apkUrl)); + } else { + runtimePermissionsHelper.showPermissionRequiredDialog(context, + context.getString(R.string.update_storage_permission_required_title), + context.getString(R.string.update_storage_permission_required), + new RuntimePermissionsHelper.PermissionRequiredDialogCallback() { + @Override + public void retryPermissionRequest() { + updateInstallRequested(message); + } + }); + } + } + }); + } + + private void createDownloadProgressDialog() { + updateDownloadDialog = new ProgressDialog(context); + updateDownloadDialog.setCancelable(false); + updateDownloadDialog.setTitle(R.string.update_install_downloading); + updateDownloadDialog.setMax(10000); + updateDownloadDialog.setProgressStyle(ProgressDialog.STYLE_HORIZONTAL); + updateDownloadDialog.setProgressNumberFormat(""); + updateDownloadDialog.show(); + } + + @Override + public void onUpdateDownloadProgress(long downloaded, long total) { + updateDownloadDialog.setProgress((int) (updateDownloadDialog.getMax() * (downloaded / (double) total))); + } + + @Override + public void onUpdateDownloadSuccess() { + updateDownloadDialog.dismiss(); + updateDownloadDialog = null; + } + + @Override + public void onUpdateDownloadFailed() { + updateDownloadDialog.dismiss(); + updateDownloadDialog = null; + new AlertDialog.Builder(context) + .setTitle(R.string.update_install_download_failed) + .setPositiveButton(R.string.ok, null) + .show(); + } + + @Override + public void onUpdateDownloadMoveFailed() { + new AlertDialog.Builder(context) + .setTitle(R.string.update_install_download_move_failed) + .setPositiveButton(R.string.ok, null) + .show(); + } + + @Override + public void onUpdateOpenInstallScreen(Intent intent) { + AndroidUtils.openIntent(intent); + } + + @Override + public void openUpdateRetryDialog(final UpdateManager.Install install) { + new AlertDialog.Builder(context) + .setTitle(R.string.update_retry_title) + .setMessage(R.string.update_retry) + .setNegativeButton(R.string.cancel, null) + .setPositiveButton(R.string.update_retry_button, new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog, int which) { + updateManager.retry(install); + } + }) + .show(); + } + + private void showMessage(int version) { + int resource = context.getResources().getIdentifier("previous_version_" + version, "string", context.getPackageName()); + if (resource != 0) { + CharSequence message = Html.fromHtml(context.getString(resource)); + + final AlertDialog dialog = new AlertDialog.Builder(context) + .setMessage(message) + .setPositiveButton(R.string.ok, null) + .create(); + dialog.show(); + dialog.setCanceledOnTouchOutside(false); + + final Button button = dialog.getButton(DialogInterface.BUTTON_POSITIVE); + button.setEnabled(false); + AndroidUtils.runOnUiThread(new Runnable() { + @Override + public void run() { + dialog.setCanceledOnTouchOutside(true); + button.setEnabled(true); + } + }, 1500); + } + } + + private void cleanupOutdatedIonFolder(Context context) { + Logger.i(TAG, "Cleaning up old ion folder"); + File ionCacheFolder = new File(context.getCacheDir() + "/ion"); + if (ionCacheFolder.exists() && ionCacheFolder.isDirectory()) { + Logger.i(TAG, "Clearing old ion folder"); + for (File file : ionCacheFolder.listFiles()) { + if (!file.delete()) { + Logger.i(TAG, "Could not delete old ion file " + file.getName()); + } + } + if (!ionCacheFolder.delete()) { + Logger.i(TAG, "Could not delete old ion folder"); + } else { + Logger.i(TAG, "Deleted old ion folder"); + } + } + } +} diff --git a/Clover/app/src/main/java/org/floens/chan/utils/IOUtils.java b/Clover/app/src/main/java/org/floens/chan/utils/IOUtils.java index 70d014a6..9a338839 100644 --- a/Clover/app/src/main/java/org/floens/chan/utils/IOUtils.java +++ b/Clover/app/src/main/java/org/floens/chan/utils/IOUtils.java @@ -20,7 +20,12 @@ package org.floens.chan.utils; import android.content.Context; +import java.io.BufferedInputStream; +import java.io.BufferedOutputStream; import java.io.Closeable; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; import java.io.IOException; import java.io.InputStream; import java.io.InputStreamReader; @@ -95,4 +100,24 @@ public class IOUtils { output.write(buffer, 0, read); } } + + /** + * Copies the {@link File} specified by {@code in} to {@code out}. + * Both streams are always closed. + * + * @param in input file + * @param out output file + * @throws IOException thrown on copy exceptions. + */ + public static void copyFile(File in, File out) throws IOException { + InputStream is = null; + OutputStream os = null; + try { + copy(is = new BufferedInputStream(new FileInputStream(in)), + os = new BufferedOutputStream(new FileOutputStream(out))); + } finally { + IOUtils.closeQuietly(is); + IOUtils.closeQuietly(os); + } + } } diff --git a/Clover/app/src/main/res/values/strings.xml b/Clover/app/src/main/res/values/strings.xml index 76c052ab..80075aca 100644 --- a/Clover/app/src/main/res/values/strings.xml +++ b/Clover/app/src/main/res/values/strings.xml @@ -100,6 +100,26 @@ along with this program. If not, see . Undo Save + App settings + Grant + + Later + Clover is up to date + Failed to check for updates. + Install + Downloading update + Download failed + Failed to move downloaded file to the Download directory. + Retry update + Clover was not updated yet. Click retry to retry the install. + retry + + Storage permission required + +"Permission to access storage is required for installing the update. + +Re-enable this permission in the app settings if you permanently disabled it." + %d minute %d minutes @@ -354,15 +374,6 @@ along with this program. If not, see . Filters - Save location - Storage permission required - -"Permission to access storage is required for browsing files. - -Re-enable this permission in the app settings if you permanently disabled it." - App settings - Grant - Select images (%1$d / %2$d) Please select images to download %1$s will be downloaded to the folder %2$s @@ -426,6 +437,7 @@ Re-enable this permission in the app settings if you permanently disabled it."Pin thread on post About + Check for updates Released under the GNU GPLv3 license Tap to see license Open Source Licenses @@ -434,6 +446,12 @@ Re-enable this permission in the app settings if you permanently disabled it."Advanced settings Advanced settings + Save location + Storage permission required + +"Permission to access storage is required for browsing files. + +Re-enable this permission in the app settings if you permanently disabled it." File save folder Error creating save folder Choose diff --git a/docs/update_api.json b/docs/update_api.json new file mode 100644 index 00000000..41ee34f6 --- /dev/null +++ b/docs/update_api.json @@ -0,0 +1,22 @@ +{ + "api_version": 1, + "messages": [ + { + "type": "update", + + "code": 56, + "date": "2017-03-18T13:23:06.614104", + "message_html": "

Clover v2.2.0 is available

A new version of Clover is available.

This release fixes stuff.
- aaa
- bbb", + + "apk": { + "default": { + "url": "https://github.com/Floens/Clover/releases/download/v2.2.0/Clover_v2.2.0.apk" + }, + "fdroid": { + "url": "https://f-droid.org/repo/org.floens.chan_56.apk" + } + } + } + ], + "check_interval": 432000000 +} diff --git a/docs/update_api.txt b/docs/update_api.txt new file mode 100644 index 00000000..23652dca --- /dev/null +++ b/docs/update_api.txt @@ -0,0 +1,29 @@ +update_api.json describes the update check api that Clover loads periodically. + +api_version +Version of this api, always 1. + +check_interval +the interval of loading the file, overrides the default interval of 5 days if set. + +messages +array of messages + + type: + type of the message, only "update" is supported + + code: + code of the new version. if this is higher than the code of the calling app then the message will be processed. + + date: + ISO8601 date, parsed but not used for now. + + message_html: + message shown to the user, parsed with Html.fromHtml() + + apk: + set of apks for each flavor. each key is only parsed if it equals to the flavor name that the app is compiled for. + + url: + url of the apk file to download and install. +