Last active
March 29, 2019 06:34
-
-
Save kamikat/7499153c2b71c084142521335581d13d to your computer and use it in GitHub Desktop.
Auto-upgrade module for Android apps using LeanCloud SDK and RxJava.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
public class AVUpdateUtils { | |
public static final long RESULT_OK = 0L; | |
public static final long RESULT_CANCEL = 1L; | |
public static final long RESULT_INSTALLING = 2L; | |
private static long downloadInBackground(Context context, String url, File file) { | |
DownloadManager.Request request = new DownloadManager.Request(Uri.parse(url)); | |
request.setAllowedNetworkTypes(DownloadManager.Request.NETWORK_WIFI); | |
request.setAllowedOverRoaming(false); | |
request.setMimeType("application/vnd.android.package-archive"); | |
request.setNotificationVisibility(DownloadManager.Request.VISIBILITY_HIDDEN); | |
request.setVisibleInDownloadsUi(false); | |
request.setDestinationUri(Uri.fromFile(file)); | |
return ((DownloadManager)context.getSystemService(Context.DOWNLOAD_SERVICE)).enqueue(request); | |
} | |
private static long downloadAndInstall(Context context, String url, File file, String versionName) { | |
DownloadManager.Request request = new DownloadManager.Request(Uri.parse(url)); | |
request.setAllowedNetworkTypes(DownloadManager.Request.NETWORK_WIFI | DownloadManager.Request.NETWORK_MOBILE); | |
request.setAllowedOverRoaming(false); | |
request.setMimeType("application/vnd.android.package-archive"); | |
request.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE); | |
request.setVisibleInDownloadsUi(false); | |
request.setDestinationUri(Uri.fromFile(file)); | |
request.setTitle(context.getString(R.string.app_name) + " " + versionName); | |
final long requestId = ((DownloadManager)context.getSystemService(Context.DOWNLOAD_SERVICE)).enqueue(request); | |
context.getApplicationContext().registerReceiver(new BroadcastReceiver() { | |
@Override | |
public void onReceive(Context context, Intent intent) { | |
long reference = intent.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, -1); | |
if (requestId == reference) { | |
installPackage(context, file); | |
} | |
} | |
}, new IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE)); | |
Toast.makeText(context, R.string.message_update_download_started, Toast.LENGTH_SHORT).show(); | |
return requestId; | |
} | |
private static void installPackage(Context context, File file) { | |
Intent install = new Intent(Intent.ACTION_VIEW); | |
install.setDataAndType(Uri.fromFile(file), "application/vnd.android.package-archive"); | |
install.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); | |
context.startActivity(install); | |
} | |
private static int checkDownloadState(Context context, long requestId, String url) { | |
DownloadManager downloadManager = (DownloadManager) context.getSystemService(Context.DOWNLOAD_SERVICE); | |
DownloadManager.Query query = new DownloadManager.Query(); | |
query.setFilterById(requestId); | |
query.setFilterByStatus(DownloadManager.STATUS_FAILED | |
| DownloadManager.STATUS_PAUSED | |
| DownloadManager.STATUS_SUCCESSFUL | |
| DownloadManager.STATUS_RUNNING | |
| DownloadManager.STATUS_PENDING); | |
Cursor c = downloadManager.query(query); | |
if (c.moveToFirst()) { | |
if (url.equals(c.getString(c.getColumnIndex(DownloadManager.COLUMN_URI)))) { | |
return c.getInt(c.getColumnIndex(DownloadManager.COLUMN_STATUS)); | |
} | |
} | |
return 0; | |
} | |
private static final Object configLock = new Object(); | |
static { | |
AVAnalytics.setOnlineConfigureListener(jsonObject -> { | |
synchronized (configLock) { | |
configLock.notify(); | |
} | |
}); | |
} | |
@SuppressWarnings({"unchecked", "ResultOfMethodCallIgnored"}) | |
public static Observable<Long> checkUpdate(Context context) { | |
return Observable | |
.fromCallable(() -> { | |
AVAnalytics.updateOnlineConfig(context); | |
synchronized (configLock) { | |
// Wait for online config parameters. | |
configLock.wait(5000); | |
} | |
return 0L; | |
}) | |
.take(1) | |
.observeOn(AndroidSchedulers.mainThread()) | |
.concatMap(ignored -> { | |
// Remove downloaded package of current installed application | |
getPackageFile(context, BuildConfig.VERSION_CODE).delete(); | |
// Parse update information from AVAnalytics | |
AVUpdateInfo info = AVUpdateInfo.getInstance(context); | |
if (info.versionCode <= BuildConfig.VERSION_CODE) { | |
// Already up to date just ok to continue | |
return Observable.just(RESULT_OK); | |
} | |
// Check download status | |
int downloadStatus = checkDownloadState(context, getLastDownloadRequest(context), info.binaryUrl); | |
File packageFile = getPackageFile(context, info.versionCode); | |
// Update information dialog and subject | |
AsyncSubject<Long> subject = AsyncSubject.create(); | |
AlertDialog.Builder builder = new AlertDialog.Builder(context); | |
builder.setTitle(context.getString(R.string.title_version_update, info.versionName)); | |
builder.setMessage(new MarkupFormatter(context).format(info.changeLog)); | |
builder.setOnCancelListener(dialog -> { | |
if (info.minimumCode <= BuildConfig.VERSION_CODE) { | |
// Ok to continue when reject a non-mandatory update | |
subject.onNext(RESULT_OK); | |
} else if (downloadStatus == DownloadManager.STATUS_RUNNING || downloadStatus == DownloadManager.STATUS_PENDING) { | |
// Waiting for an ongoing download of mandatory update | |
subject.onNext(RESULT_INSTALLING); | |
} else { | |
// User rejects a mandatory update | |
subject.onNext(RESULT_CANCEL); | |
} | |
subject.onCompleted(); | |
}); | |
if (info.minimumCode > BuildConfig.VERSION_CODE) { | |
// If there be a mandatory update, check download status immediately | |
switch (downloadStatus) { | |
case DownloadManager.STATUS_RUNNING: | |
case DownloadManager.STATUS_PENDING: | |
Toast.makeText(context, R.string.message_update_download_already_started, Toast.LENGTH_SHORT).show(); | |
return Observable.just(RESULT_INSTALLING); | |
case DownloadManager.STATUS_SUCCESSFUL: | |
if (packageFile.exists()) { | |
// Show update dialog to open install | |
builder.setPositiveButton(R.string.action_update, (dialog, i) -> { | |
installPackage(context, packageFile); | |
subject.onNext(RESULT_INSTALLING); | |
subject.onCompleted(); | |
}).show(); | |
return subject; | |
} | |
default: | |
// Show update dialog to start download | |
builder.setPositiveButton(R.string.action_update, (dialog, i) -> { | |
packageFile.delete(); | |
setLastDownloadRequest(context, | |
downloadAndInstall(context, info.binaryUrl, packageFile, info.versionName)); | |
subject.onNext(RESULT_INSTALLING); | |
subject.onCompleted(); | |
}).show(); | |
return subject; | |
} | |
} else { | |
// If the update is not mandatory, checks whether the update package is downloaded | |
if (packageFile.exists() && downloadStatus == DownloadManager.STATUS_SUCCESSFUL) { | |
// Package file is downloaded and ready for install | |
builder.setPositiveButton(R.string.action_update, (dialog, i) -> { | |
installPackage(context, packageFile); | |
subject.onNext(RESULT_INSTALLING); | |
subject.onCompleted(); | |
}).show(); | |
return subject; | |
} else { | |
// Package is downloading, otherwise start a background download request | |
switch (downloadStatus) { | |
case DownloadManager.STATUS_RUNNING: | |
case DownloadManager.STATUS_PENDING: | |
return Observable.just(RESULT_OK); | |
default: | |
packageFile.delete(); | |
setLastDownloadRequest(context, | |
downloadInBackground(context, info.binaryUrl, packageFile)); | |
return Observable.just(RESULT_OK); | |
} | |
} | |
} | |
}); | |
} | |
private static File getPackageFile(Context context, int versionCode) { | |
return new File( | |
context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS), | |
BuildConfig.APPLICATION_ID + "-" + versionCode + ".apk"); | |
} | |
private static long getLastDownloadRequest(Context context) { | |
return context.getSharedPreferences("__update", Context.MODE_PRIVATE).getLong("requestId", 0L); | |
} | |
@SuppressLint("CommitPrefEdits") | |
private static void setLastDownloadRequest(Context context, long requestId) { | |
context.getSharedPreferences("__update", Context.MODE_PRIVATE).edit().putLong("requestId", requestId).commit(); | |
} | |
private static class AVUpdateInfo { | |
int versionCode; | |
int minimumCode; | |
String versionName; | |
String changeLog; | |
String binaryUrl; | |
public static AVUpdateInfo getInstance(Context context) { | |
AVUpdateInfo info = new AVUpdateInfo(); | |
info.versionCode = Integer.valueOf(AVAnalytics.getConfigParams(context, "ANDROID_LATEST_VERSION_CODE")); | |
info.minimumCode = Integer.valueOf(AVAnalytics.getConfigParams(context, "ANDROID_MINIMUM_VERSION_CODE")); | |
info.versionName = AVAnalytics.getConfigParams(context, "ANDROID_LATEST_VERSION_NAME"); | |
info.changeLog = AVAnalytics.getConfigParams(context, "ANDROID_LATEST_CHANGELOG"); | |
info.binaryUrl = AVAnalytics.getConfigParams(context, "ANDROID_LATEST_BINARY_URL"); | |
return info; | |
} | |
} | |
} |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
public class MarkupFormatter implements Html.TagHandler { | |
public enum Tag { | |
LI(ListItemSpan.class) { | |
@Override | |
public Object createSpan(Context context, Map<String, String> attributes) { | |
return new ListItemSpan(); | |
} | |
}; | |
private final Class<?> mType; | |
Tag(Class<?> type) { | |
mType = type; | |
} | |
public Class<?> getType() { | |
return mType; | |
} | |
public abstract Object createSpan(Context context, Map<String, String> attributes); | |
public static Tag fromString(String text) { | |
if (text != null) { | |
for (Tag b : Tag.values()) { | |
if (text.equalsIgnoreCase(b.name())) { | |
return b; | |
} | |
} | |
} | |
return null; | |
} | |
} | |
private final Context mContext; | |
public MarkupFormatter(Context context) { | |
mContext = context; | |
} | |
@Override | |
public void handleTag(boolean opening, String tag, Editable output, XMLReader xmlReader) { | |
Tag tagSpan = Tag.fromString(tag); | |
if (tagSpan != null) { | |
int len = output.length(); | |
if (opening) { | |
Object span = tagSpan.createSpan(mContext, processAttributes(xmlReader)); | |
if (span instanceof TextDecoration) { | |
((TextDecoration) span).insertBefore(output); | |
} | |
output.setSpan(span, len, len, Spannable.SPAN_MARK_MARK); | |
} else { | |
Object span = getLastSpan(output, tagSpan.getType()); | |
int where = output.getSpanStart(span); | |
output.removeSpan(span); | |
if (span instanceof TextDecoration) { | |
((TextDecoration) span).appendAfter(output); | |
len = output.length(); | |
} | |
if (where != len) { | |
output.setSpan(span, where, len, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE); | |
} | |
} | |
} | |
} | |
private Map<String, String> processAttributes(XMLReader xmlReader) { | |
final Map<String, String> attributes = new HashMap<>(); | |
try { | |
Field elementField = xmlReader.getClass().getDeclaredField("theNewElement"); | |
elementField.setAccessible(true); | |
Object element = elementField.get(xmlReader); | |
Field attsField = element.getClass().getDeclaredField("theAtts"); | |
attsField.setAccessible(true); | |
Object atts = attsField.get(element); | |
Field dataField = atts.getClass().getDeclaredField("data"); | |
dataField.setAccessible(true); | |
String[] data = (String[])dataField.get(atts); | |
Field lengthField = atts.getClass().getDeclaredField("length"); | |
lengthField.setAccessible(true); | |
int len = lengthField.getInt(atts); | |
/** | |
* MSH: Look for supported attributes and add to hash map. | |
* This is as tight as things can get :) | |
* The data index is "just" where the keys and values are stored. | |
**/ | |
for(int i = 0; i < len; i++) { | |
attributes.put(data[i * 5 + 1], data[i * 5 + 4]); | |
} | |
} catch (Exception ignored) { } | |
return attributes; | |
} | |
private Object getLastSpan(Editable text, Class kind) { | |
Object[] objs = text.getSpans(0, text.length(), kind); | |
if (objs.length == 0) { | |
return null; | |
} else { | |
for (int i = objs.length; i > 0; i--) { | |
if (text.getSpanFlags(objs[i - 1]) == Spannable.SPAN_MARK_MARK) { | |
return objs[i - 1]; | |
} | |
} | |
return null; | |
} | |
} | |
public void clearSpan(Tag tag, Editable editable) { | |
Object[] spans = editable.getSpans(0, editable.length(), tag.getType()); | |
for (Object span : spans) { | |
editable.removeSpan(span); | |
} | |
} | |
public Spanned format(int res, Object... args) { | |
return format(mContext.getString(res), args); | |
} | |
public Spanned format(String template, Object... args) { | |
Object[] safeArgs = new Object[args.length]; | |
for (int i = 0; i != args.length; i++) { | |
if (args[i] instanceof String) { | |
safeArgs[i] = TextUtils.htmlEncode(String.valueOf(args[i])); | |
} else { | |
safeArgs[i] = args[i]; | |
} | |
} | |
return Html.fromHtml(String.format(template, safeArgs), null, this); | |
} | |
public interface TextDecoration { | |
void insertBefore(Editable editable); | |
void appendAfter(Editable editable); | |
} | |
public static class ListItemSpan implements TextDecoration { | |
@Override | |
public void insertBefore(Editable editable) { | |
editable.append("γ» "); | |
} | |
@Override | |
public void appendAfter(Editable editable) { | |
editable.append("\n"); | |
} | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
This gist implements auto-upgrade feature for Android apps using LeanCloud SDK and RxJava.
You are required to fill in some of the string resources used in code above.
Features
Usage
Add permission declaration to
<application/>
section ofAndroidManifest.xml
Checks for update in a
SplashActivity
for exampleThe update parameter is configured via LeanCloud service: