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.
multisite^2
Floens 8 years ago
parent 4b63aacf98
commit 5fbce3e31d
  1. 35
      Clover/app/build.gradle
  2. 162
      Clover/app/src/main/java/org/floens/chan/core/net/UpdateApiRequest.java
  3. 7
      Clover/app/src/main/java/org/floens/chan/core/settings/ChanSettings.java
  4. 49
      Clover/app/src/main/java/org/floens/chan/core/settings/LongSetting.java
  5. 238
      Clover/app/src/main/java/org/floens/chan/core/update/UpdateManager.java
  6. 11
      Clover/app/src/main/java/org/floens/chan/ui/activity/StartActivity.java
  7. 9
      Clover/app/src/main/java/org/floens/chan/ui/controller/MainSettingsController.java
  8. 32
      Clover/app/src/main/java/org/floens/chan/ui/controller/SaveLocationController.java
  9. 118
      Clover/app/src/main/java/org/floens/chan/ui/helper/PreviousVersionHandler.java
  10. 36
      Clover/app/src/main/java/org/floens/chan/ui/helper/RuntimePermissionsHelper.java
  11. 282
      Clover/app/src/main/java/org/floens/chan/ui/helper/VersionHandler.java
  12. 25
      Clover/app/src/main/java/org/floens/chan/utils/IOUtils.java
  13. 36
      Clover/app/src/main/res/values/strings.xml
  14. 22
      docs/update_api.json
  15. 29
      docs/update_api.txt

@ -18,7 +18,6 @@ android {
buildToolsVersion '25.0.1' buildToolsVersion '25.0.1'
defaultConfig { defaultConfig {
applicationId "org.floens.chan"
minSdkVersion 15 minSdkVersion 15
targetSdkVersion 25 targetSdkVersion 25
@ -66,6 +65,32 @@ android {
is.close() 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 { buildTypes {
release { release {
if (doSign) { if (doSign) {
@ -82,14 +107,6 @@ android {
} }
} }
productFlavors {
normal {
applicationId = "org.floens.chan"
resValue "string", "app_name", "Clover"
resValue "string", "app_flavor_name", ""
}
}
sourceSets { sourceSets {
beta.java.srcDirs = ['src/release/java'] beta.java.srcDirs = ['src/release/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 <http://www.gnu.org/licenses/>.
*/
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<UpdateApiRequest.UpdateApiResponse> {
public static final String TYPE_UPDATE = "update";
private static final int API_VERSION = 1;
private String forFlavor;
public UpdateApiRequest(Response.Listener<UpdateApiResponse> 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<UpdateApiMessage> 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;
}
}

@ -24,6 +24,7 @@ import android.text.TextUtils;
import org.floens.chan.Chan; import org.floens.chan.Chan;
import org.floens.chan.R; import org.floens.chan.R;
import org.floens.chan.core.manager.WatchManager; 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.ui.adapter.PostsFilter;
import org.floens.chan.utils.AndroidUtils; import org.floens.chan.utils.AndroidUtils;
@ -159,6 +160,9 @@ public class ChanSettings {
public static final CounterSetting replyOpenCounter; public static final CounterSetting replyOpenCounter;
public static final CounterSetting threadOpenCounter; public static final CounterSetting threadOpenCounter;
public static final LongSetting updateCheckTime;
public static final LongSetting updateCheckInterval;
public enum TestOptions implements OptionSettingItem { public enum TestOptions implements OptionSettingItem {
ONE("one"), ONE("one"),
TWO("two"), TWO("two"),
@ -288,6 +292,9 @@ public class ChanSettings {
replyOpenCounter = new CounterSetting(p, "counter_reply_open"); replyOpenCounter = new CounterSetting(p, "counter_reply_open");
threadOpenCounter = new CounterSetting(p, "counter_thread_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) // Old (but possibly still in some users phone)
// preference_board_view_mode default "list" // preference_board_view_mode default "list"
// preference_board_editor_filler default false // preference_board_editor_filler default false

@ -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 <http://www.gnu.org/licenses/>.
*/
package org.floens.chan.core.settings;
import android.content.SharedPreferences;
public class LongSetting extends Setting<Long> {
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();
}
}
}

@ -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 <http://www.gnu.org/licenses/>.
*/
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.
* <p>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<UpdateApiRequest.UpdateApiResponse>() {
@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);
}
}

@ -51,7 +51,7 @@ import org.floens.chan.ui.controller.StyledToolbarNavigationController;
import org.floens.chan.ui.controller.ThreadSlideController; import org.floens.chan.ui.controller.ThreadSlideController;
import org.floens.chan.ui.controller.ViewThreadController; import org.floens.chan.ui.controller.ViewThreadController;
import org.floens.chan.ui.helper.ImagePickDelegate; 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.helper.RuntimePermissionsHelper;
import org.floens.chan.ui.state.ChanState; import org.floens.chan.ui.state.ChanState;
import org.floens.chan.ui.theme.ThemeHelper; import org.floens.chan.ui.theme.ThemeHelper;
@ -76,6 +76,7 @@ public class StartActivity extends AppCompatActivity implements NfcAdapter.Creat
private ImagePickDelegate imagePickDelegate; private ImagePickDelegate imagePickDelegate;
private RuntimePermissionsHelper runtimePermissionsHelper; private RuntimePermissionsHelper runtimePermissionsHelper;
private VersionHandler versionHandler;
public StartActivity() { public StartActivity() {
boardManager = Chan.getBoardManager(); boardManager = Chan.getBoardManager();
@ -89,6 +90,7 @@ public class StartActivity extends AppCompatActivity implements NfcAdapter.Creat
imagePickDelegate = new ImagePickDelegate(this); imagePickDelegate = new ImagePickDelegate(this);
runtimePermissionsHelper = new RuntimePermissionsHelper(this); runtimePermissionsHelper = new RuntimePermissionsHelper(this);
versionHandler = new VersionHandler(this, runtimePermissionsHelper);
contentView = (ViewGroup) findViewById(android.R.id.content); 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)); browseController.loadBoard(boardManager.getSavedBoards().get(0));
} }
PreviousVersionHandler previousVersionHandler = new PreviousVersionHandler(); versionHandler.run();
previousVersionHandler.run(this);
} }
private void setupLayout() { private void setupLayout() {
@ -330,6 +331,10 @@ public class StartActivity extends AppCompatActivity implements NfcAdapter.Creat
return imagePickDelegate; return imagePickDelegate;
} }
public VersionHandler getVersionHandler() {
return versionHandler;
}
public RuntimePermissionsHelper getRuntimePermissionsHelper() { public RuntimePermissionsHelper getRuntimePermissionsHelper() {
return runtimePermissionsHelper; return runtimePermissionsHelper;
} }

@ -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()); int extraAbouts = context.getResources().getIdentifier("extra_abouts", "array", context.getPackageName());
if (extraAbouts != 0) { if (extraAbouts != 0) {
String[] abouts = context.getResources().getStringArray(extraAbouts); String[] abouts = context.getResources().getStringArray(extraAbouts);

@ -19,12 +19,7 @@ package org.floens.chan.ui.controller;
import android.Manifest; import android.Manifest;
import android.content.Context; 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.design.widget.FloatingActionButton;
import android.support.v7.app.AlertDialog;
import android.view.View; import android.view.View;
import org.floens.chan.R; 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.adapter.FilesAdapter;
import org.floens.chan.ui.helper.RuntimePermissionsHelper; import org.floens.chan.ui.helper.RuntimePermissionsHelper;
import org.floens.chan.ui.layout.FilesLayout; import org.floens.chan.ui.layout.FilesLayout;
import org.floens.chan.utils.AndroidUtils;
import java.io.File; 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 { public class SaveLocationController extends Controller implements FileWatcher.FileWatcherCallback, FilesAdapter.Callback, FilesLayout.Callback, View.OnClickListener {
private static final String TAG = "SaveLocationController"; private static final String TAG = "SaveLocationController";
@ -121,26 +118,17 @@ public class SaveLocationController extends Controller implements FileWatcher.Fi
if (gotPermission) { if (gotPermission) {
initialize(); initialize();
} else { } else {
new AlertDialog.Builder(context) runtimePermissionsHelper.showPermissionRequiredDialog(
.setTitle(R.string.write_permission_required_title) context,
.setMessage(R.string.write_permission_required) context.getString(save_location_storage_permission_required_title),
.setCancelable(false) context.getString(save_location_storage_permission_required),
.setNeutralButton(R.string.write_permission_app_settings, new DialogInterface.OnClickListener() { new RuntimePermissionsHelper.PermissionRequiredDialogCallback() {
@Override @Override
public void onClick(DialogInterface dialog, int which) { public void retryPermissionRequest() {
requestPermission(); 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() {
@Override
public void onClick(DialogInterface dialog, int which) {
requestPermission();
} }
}) );
.show();
} }
} }
}); });

@ -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 <http://www.gnu.org/licenses/>.
*/
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");
}
}
}
}

@ -18,10 +18,19 @@
package org.floens.chan.ui.helper; package org.floens.chan.ui.helper;
import android.app.Activity; import android.app.Activity;
import android.content.Context;
import android.content.DialogInterface;
import android.content.Intent;
import android.content.pm.PackageManager; import android.content.pm.PackageManager;
import android.net.Uri;
import android.provider.Settings;
import android.support.annotation.NonNull; import android.support.annotation.NonNull;
import android.support.v4.app.ActivityCompat; import android.support.v4.app.ActivityCompat;
import android.support.v4.content.ContextCompat; 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; 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 class CallbackHolder {
private Callback callback; private Callback callback;
private String permission; private String permission;

@ -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 <http://www.gnu.org/licenses/>.
*/
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");
}
}
}
}

@ -20,7 +20,12 @@ package org.floens.chan.utils;
import android.content.Context; import android.content.Context;
import java.io.BufferedInputStream;
import java.io.BufferedOutputStream;
import java.io.Closeable; import java.io.Closeable;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException; import java.io.IOException;
import java.io.InputStream; import java.io.InputStream;
import java.io.InputStreamReader; import java.io.InputStreamReader;
@ -95,4 +100,24 @@ public class IOUtils {
output.write(buffer, 0, read); 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);
}
}
} }

@ -100,6 +100,26 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<string name="undo">Undo</string> <string name="undo">Undo</string>
<string name="save">Save</string> <string name="save">Save</string>
<string name="permission_app_settings">App settings</string>
<string name="permission_grant">Grant</string>
<string name="update_later">Later</string>
<string name="update_none">Clover is up to date</string>
<string name="update_check_failed">Failed to check for updates.</string>
<string name="update_install">Install</string>
<string name="update_install_downloading">Downloading update</string>
<string name="update_install_download_failed">Download failed</string>
<string name="update_install_download_move_failed">Failed to move downloaded file to the Download directory.</string>
<string name="update_retry_title">Retry update</string>
<string name="update_retry">Clover was not updated yet. Click retry to retry the install.</string>
<string name="update_retry_button">retry</string>
<string name="update_storage_permission_required_title">Storage permission required</string>
<string name="update_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."</string>
<plurals name="minutes"> <plurals name="minutes">
<item quantity="one">%d minute</item> <item quantity="one">%d minute</item>
<item quantity="other">%d minutes</item> <item quantity="other">%d minutes</item>
@ -354,15 +374,6 @@ along with this program. If not, see <http://www.gnu.org/licenses/>.
<string name="filters_screen">Filters</string> <string name="filters_screen">Filters</string>
<string name="save_location_screen">Save location</string>
<string name="write_permission_required_title">Storage permission required</string>
<string name="write_permission_required">
"Permission to access storage is required for browsing files.
Re-enable this permission in the app settings if you permanently disabled it."</string>
<string name="write_permission_app_settings">App settings</string>
<string name="write_permission_grant">Grant</string>
<string name="album_download_screen">Select images (%1$d / %2$d)</string> <string name="album_download_screen">Select images (%1$d / %2$d)</string>
<string name="album_download_none_checked">Please select images to download</string> <string name="album_download_none_checked">Please select images to download</string>
<string name="album_download_confirm">%1$s will be downloaded to the folder %2$s</string> <string name="album_download_confirm">%1$s will be downloaded to the folder %2$s</string>
@ -426,6 +437,7 @@ Re-enable this permission in the app settings if you permanently disabled it."</
<string name="setting_post_pin">Pin thread on post</string> <string name="setting_post_pin">Pin thread on post</string>
<string name="settings_group_about">About</string> <string name="settings_group_about">About</string>
<string name="settings_update_check">Check for updates</string>
<string name="settings_about_license">Released under the GNU GPLv3 license</string> <string name="settings_about_license">Released under the GNU GPLv3 license</string>
<string name="settings_about_license_description">Tap to see license</string> <string name="settings_about_license_description">Tap to see license</string>
<string name="settings_about_licenses">Open Source Licenses</string> <string name="settings_about_licenses">Open Source Licenses</string>
@ -434,6 +446,12 @@ Re-enable this permission in the app settings if you permanently disabled it."</
<string name="settings_screen_advanced">Advanced settings</string> <string name="settings_screen_advanced">Advanced settings</string>
<string name="settings_group_advanced">Advanced settings</string> <string name="settings_group_advanced">Advanced settings</string>
<string name="save_location_screen">Save location</string>
<string name="save_location_storage_permission_required_title">Storage permission required</string>
<string name="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."</string>
<string name="setting_save_folder">File save folder</string> <string name="setting_save_folder">File save folder</string>
<string name="setting_save_folder_error_create_folder">Error creating save folder</string> <string name="setting_save_folder_error_create_folder">Error creating save folder</string>
<string name="setting_folder_pick_ok">Choose</string> <string name="setting_folder_pick_ok">Choose</string>

@ -0,0 +1,22 @@
{
"api_version": 1,
"messages": [
{
"type": "update",
"code": 56,
"date": "2017-03-18T13:23:06.614104",
"message_html": "<h2>Clover v2.2.0 is available</h2>A new version of Clover is available.<br><br>This release fixes stuff.<br>- aaa<br>- 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
}

@ -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.
Loading…
Cancel
Save