Simplify APK updates and harden R2 cache headers
This commit is contained in:
@@ -7,8 +7,8 @@ android {
|
|||||||
applicationId "com.dewemoji.app"
|
applicationId "com.dewemoji.app"
|
||||||
minSdkVersion rootProject.ext.minSdkVersion
|
minSdkVersion rootProject.ext.minSdkVersion
|
||||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||||
versionCode 1
|
versionCode 3
|
||||||
versionName "1.0"
|
versionName "1.0.2"
|
||||||
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
|
||||||
aaptOptions {
|
aaptOptions {
|
||||||
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
|
||||||
|
|||||||
@@ -38,5 +38,4 @@
|
|||||||
<!-- Permissions -->
|
<!-- Permissions -->
|
||||||
|
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
|
||||||
</manifest>
|
</manifest>
|
||||||
|
|||||||
@@ -1,26 +1,16 @@
|
|||||||
package com.dewemoji.app;
|
package com.dewemoji.app;
|
||||||
|
|
||||||
import android.app.AlertDialog;
|
import android.app.AlertDialog;
|
||||||
import android.app.DownloadManager;
|
|
||||||
import android.content.ActivityNotFoundException;
|
import android.content.ActivityNotFoundException;
|
||||||
import android.content.BroadcastReceiver;
|
|
||||||
import android.content.Context;
|
|
||||||
import android.content.DialogInterface;
|
|
||||||
import android.content.Intent;
|
import android.content.Intent;
|
||||||
import android.content.IntentFilter;
|
|
||||||
import android.content.pm.PackageInfo;
|
import android.content.pm.PackageInfo;
|
||||||
import android.content.pm.PackageManager;
|
import android.content.pm.PackageManager;
|
||||||
import android.database.Cursor;
|
|
||||||
import android.net.Uri;
|
import android.net.Uri;
|
||||||
import android.os.Build;
|
import android.os.Build;
|
||||||
import android.os.Bundle;
|
import android.os.Bundle;
|
||||||
import android.os.Environment;
|
|
||||||
import android.util.Log;
|
import android.util.Log;
|
||||||
import android.widget.Toast;
|
import android.widget.Toast;
|
||||||
|
|
||||||
import androidx.annotation.Nullable;
|
|
||||||
import androidx.core.content.FileProvider;
|
|
||||||
import androidx.core.content.ContextCompat;
|
|
||||||
import androidx.core.view.WindowCompat;
|
import androidx.core.view.WindowCompat;
|
||||||
import androidx.core.view.WindowInsetsCompat;
|
import androidx.core.view.WindowInsetsCompat;
|
||||||
import androidx.core.view.WindowInsetsControllerCompat;
|
import androidx.core.view.WindowInsetsControllerCompat;
|
||||||
@@ -35,41 +25,20 @@ import java.io.InputStream;
|
|||||||
import java.net.HttpURLConnection;
|
import java.net.HttpURLConnection;
|
||||||
import java.net.URL;
|
import java.net.URL;
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
import java.security.MessageDigest;
|
|
||||||
import java.util.Locale;
|
|
||||||
|
|
||||||
public class MainActivity extends BridgeActivity {
|
public class MainActivity extends BridgeActivity {
|
||||||
private static final String TAG = "DewemojiUpdater";
|
private static final String TAG = "DewemojiUpdater";
|
||||||
private static final String VERSION_URL = "https://dewemoji.com/downloads/version.json";
|
private static final String VERSION_URL = "https://dewemoji.com/downloads/version.json";
|
||||||
private static final int CONNECT_TIMEOUT_MS = 10_000;
|
private static final int CONNECT_TIMEOUT_MS = 10000;
|
||||||
private static final int READ_TIMEOUT_MS = 15_000;
|
private static final int READ_TIMEOUT_MS = 15000;
|
||||||
|
|
||||||
@Nullable
|
|
||||||
private DownloadManager downloadManager;
|
|
||||||
private long activeDownloadId = -1L;
|
|
||||||
@Nullable
|
|
||||||
private String activeExpectedSha = null;
|
|
||||||
@Nullable
|
|
||||||
private BroadcastReceiver downloadReceiver = null;
|
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
protected void onCreate(Bundle savedInstanceState) {
|
protected void onCreate(Bundle savedInstanceState) {
|
||||||
super.onCreate(savedInstanceState);
|
super.onCreate(savedInstanceState);
|
||||||
hideSystemBars();
|
hideSystemBars();
|
||||||
downloadManager = (DownloadManager) getSystemService(Context.DOWNLOAD_SERVICE);
|
|
||||||
registerDownloadReceiver();
|
|
||||||
checkForUpdates(false);
|
checkForUpdates(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onDestroy() {
|
|
||||||
super.onDestroy();
|
|
||||||
if (downloadReceiver != null) {
|
|
||||||
unregisterReceiver(downloadReceiver);
|
|
||||||
downloadReceiver = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onWindowFocusChanged(boolean hasFocus) {
|
public void onWindowFocusChanged(boolean hasFocus) {
|
||||||
super.onWindowFocusChanged(hasFocus);
|
super.onWindowFocusChanged(hasFocus);
|
||||||
@@ -95,6 +64,7 @@ public class MainActivity extends BridgeActivity {
|
|||||||
if (metadata == null) {
|
if (metadata == null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
long installedVersion = getInstalledVersionCode();
|
long installedVersion = getInstalledVersionCode();
|
||||||
if (metadata.versionCode <= installedVersion) {
|
if (metadata.versionCode <= installedVersion) {
|
||||||
if (manual) {
|
if (manual) {
|
||||||
@@ -104,6 +74,7 @@ public class MainActivity extends BridgeActivity {
|
|||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
runOnUiThread(() -> showUpdateDialog(metadata));
|
runOnUiThread(() -> showUpdateDialog(metadata));
|
||||||
} catch (Exception ex) {
|
} catch (Exception ex) {
|
||||||
Log.w(TAG, "Update check failed", ex);
|
Log.w(TAG, "Update check failed", ex);
|
||||||
@@ -116,7 +87,6 @@ public class MainActivity extends BridgeActivity {
|
|||||||
}).start();
|
}).start();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Nullable
|
|
||||||
private UpdateMetadata fetchVersionMetadata() throws Exception {
|
private UpdateMetadata fetchVersionMetadata() throws Exception {
|
||||||
HttpURLConnection conn = (HttpURLConnection) new URL(VERSION_URL).openConnection();
|
HttpURLConnection conn = (HttpURLConnection) new URL(VERSION_URL).openConnection();
|
||||||
conn.setRequestMethod("GET");
|
conn.setRequestMethod("GET");
|
||||||
@@ -159,14 +129,11 @@ public class MainActivity extends BridgeActivity {
|
|||||||
if (!metadata.notes.isEmpty()) {
|
if (!metadata.notes.isEmpty()) {
|
||||||
message.append("\n\n").append(metadata.notes);
|
message.append("\n\n").append(metadata.notes);
|
||||||
}
|
}
|
||||||
if (metadata.force) {
|
|
||||||
message.append("\n\nThis update is required.");
|
|
||||||
}
|
|
||||||
|
|
||||||
AlertDialog.Builder builder = new AlertDialog.Builder(this)
|
AlertDialog.Builder builder = new AlertDialog.Builder(this)
|
||||||
.setTitle("Update Dewemoji")
|
.setTitle("Update Dewemoji")
|
||||||
.setMessage(message.toString())
|
.setMessage(message.toString())
|
||||||
.setPositiveButton("Update", (dialog, which) -> startApkDownload(metadata))
|
.setPositiveButton("Download", (dialog, which) -> openApkDownloadPage(metadata.apkUrl))
|
||||||
.setCancelable(!metadata.force);
|
.setCancelable(!metadata.force);
|
||||||
|
|
||||||
if (!metadata.force) {
|
if (!metadata.force) {
|
||||||
@@ -176,150 +143,23 @@ public class MainActivity extends BridgeActivity {
|
|||||||
builder.show();
|
builder.show();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void startApkDownload(UpdateMetadata metadata) {
|
private void openApkDownloadPage(String apkUrl) {
|
||||||
if (downloadManager == null) {
|
|
||||||
Toast.makeText(this, "Download manager unavailable", Toast.LENGTH_SHORT).show();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
Uri uri = Uri.parse(metadata.apkUrl);
|
|
||||||
DownloadManager.Request request = new DownloadManager.Request(uri);
|
|
||||||
request.setTitle("Dewemoji update");
|
|
||||||
request.setDescription("Downloading version " + metadata.versionName);
|
|
||||||
request.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED);
|
|
||||||
request.setDestinationInExternalPublicDir(
|
|
||||||
Environment.DIRECTORY_DOWNLOADS,
|
|
||||||
"dewemoji-latest.apk"
|
|
||||||
);
|
|
||||||
request.setMimeType("application/vnd.android.package-archive");
|
|
||||||
|
|
||||||
activeExpectedSha = metadata.sha256.toLowerCase(Locale.US);
|
|
||||||
activeDownloadId = downloadManager.enqueue(request);
|
|
||||||
Toast.makeText(this, "Downloading update...", Toast.LENGTH_SHORT).show();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void registerDownloadReceiver() {
|
|
||||||
downloadReceiver = new BroadcastReceiver() {
|
|
||||||
@Override
|
|
||||||
public void onReceive(Context context, Intent intent) {
|
|
||||||
if (!DownloadManager.ACTION_DOWNLOAD_COMPLETE.equals(intent.getAction())) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
long downloadId = intent.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, -1L);
|
|
||||||
if (downloadId <= 0 || downloadId != activeDownloadId) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
verifyAndInstallDownloadedApk(downloadId);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
ContextCompat.registerReceiver(
|
|
||||||
this,
|
|
||||||
downloadReceiver,
|
|
||||||
new IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE),
|
|
||||||
ContextCompat.RECEIVER_NOT_EXPORTED
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void verifyAndInstallDownloadedApk(long downloadId) {
|
|
||||||
if (downloadManager == null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
DownloadManager.Query query = new DownloadManager.Query().setFilterById(downloadId);
|
|
||||||
try (Cursor cursor = downloadManager.query(query)) {
|
|
||||||
if (cursor == null || !cursor.moveToFirst()) {
|
|
||||||
showUpdateError("Download record not found");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
int statusCol = cursor.getColumnIndex(DownloadManager.COLUMN_STATUS);
|
|
||||||
int uriCol = cursor.getColumnIndex(DownloadManager.COLUMN_LOCAL_URI);
|
|
||||||
int reasonCol = cursor.getColumnIndex(DownloadManager.COLUMN_REASON);
|
|
||||||
int status = statusCol >= 0 ? cursor.getInt(statusCol) : DownloadManager.STATUS_FAILED;
|
|
||||||
String localUri = uriCol >= 0 ? cursor.getString(uriCol) : null;
|
|
||||||
int reason = reasonCol >= 0 ? cursor.getInt(reasonCol) : -1;
|
|
||||||
|
|
||||||
if (status != DownloadManager.STATUS_SUCCESSFUL || localUri == null || localUri.isEmpty()) {
|
|
||||||
showUpdateError("Download failed (" + reason + ")");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
Uri apkUri = Uri.parse(localUri);
|
|
||||||
String localSha = computeSha256(apkUri);
|
|
||||||
if (localSha == null || activeExpectedSha == null || !localSha.equalsIgnoreCase(activeExpectedSha)) {
|
|
||||||
showUpdateError("Checksum mismatch");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
installApk(apkUri);
|
|
||||||
} catch (Exception ex) {
|
|
||||||
Log.e(TAG, "Failed to verify update APK", ex);
|
|
||||||
showUpdateError("Update verification failed");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Nullable
|
|
||||||
private String computeSha256(Uri uri) {
|
|
||||||
try (InputStream input = getContentResolver().openInputStream(uri)) {
|
|
||||||
if (input == null) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
MessageDigest digest = MessageDigest.getInstance("SHA-256");
|
|
||||||
byte[] buffer = new byte[8192];
|
|
||||||
int read;
|
|
||||||
while ((read = input.read(buffer)) > 0) {
|
|
||||||
digest.update(buffer, 0, read);
|
|
||||||
}
|
|
||||||
byte[] bytes = digest.digest();
|
|
||||||
StringBuilder out = new StringBuilder(bytes.length * 2);
|
|
||||||
for (byte b : bytes) {
|
|
||||||
out.append(String.format(Locale.US, "%02x", b));
|
|
||||||
}
|
|
||||||
return out.toString();
|
|
||||||
} catch (Exception ex) {
|
|
||||||
Log.e(TAG, "Failed to compute checksum", ex);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void installApk(Uri downloadUri) {
|
|
||||||
try {
|
try {
|
||||||
Uri installUri = downloadUri;
|
Intent intent = new Intent(Intent.ACTION_VIEW, Uri.parse(apkUrl));
|
||||||
if ("file".equals(downloadUri.getScheme())) {
|
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
|
||||||
installUri = FileProvider.getUriForFile(
|
startActivity(intent);
|
||||||
this,
|
|
||||||
getPackageName() + ".fileprovider",
|
|
||||||
new java.io.File(downloadUri.getPath())
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Intent installIntent = new Intent(Intent.ACTION_VIEW);
|
|
||||||
installIntent.setDataAndType(installUri, "application/vnd.android.package-archive");
|
|
||||||
installIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
|
|
||||||
installIntent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
|
|
||||||
startActivity(installIntent);
|
|
||||||
} catch (ActivityNotFoundException ex) {
|
} catch (ActivityNotFoundException ex) {
|
||||||
showUpdateError("No installer found");
|
Toast.makeText(this, "No browser found", Toast.LENGTH_SHORT).show();
|
||||||
} catch (Exception ex) {
|
} catch (Exception ex) {
|
||||||
Log.e(TAG, "Failed to launch APK installer", ex);
|
Log.e(TAG, "Failed to open APK URL", ex);
|
||||||
showUpdateError("Cannot open installer");
|
Toast.makeText(this, "Cannot open update URL", Toast.LENGTH_SHORT).show();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void showUpdateError(String message) {
|
|
||||||
runOnUiThread(() -> new AlertDialog.Builder(this)
|
|
||||||
.setTitle("Update failed")
|
|
||||||
.setMessage(message)
|
|
||||||
.setPositiveButton("OK", (DialogInterface dialog, int which) -> dialog.dismiss())
|
|
||||||
.show());
|
|
||||||
}
|
|
||||||
|
|
||||||
private static class UpdateMetadata {
|
private static class UpdateMetadata {
|
||||||
final String versionName;
|
final String versionName;
|
||||||
final long versionCode;
|
final long versionCode;
|
||||||
final String apkUrl;
|
final String apkUrl;
|
||||||
final String sha256;
|
|
||||||
final String notes;
|
final String notes;
|
||||||
final boolean force;
|
final boolean force;
|
||||||
|
|
||||||
@@ -327,14 +167,12 @@ public class MainActivity extends BridgeActivity {
|
|||||||
String versionName,
|
String versionName,
|
||||||
long versionCode,
|
long versionCode,
|
||||||
String apkUrl,
|
String apkUrl,
|
||||||
String sha256,
|
|
||||||
String notes,
|
String notes,
|
||||||
boolean force
|
boolean force
|
||||||
) {
|
) {
|
||||||
this.versionName = versionName;
|
this.versionName = versionName;
|
||||||
this.versionCode = versionCode;
|
this.versionCode = versionCode;
|
||||||
this.apkUrl = apkUrl;
|
this.apkUrl = apkUrl;
|
||||||
this.sha256 = sha256;
|
|
||||||
this.notes = notes;
|
this.notes = notes;
|
||||||
this.force = force;
|
this.force = force;
|
||||||
}
|
}
|
||||||
@@ -343,15 +181,14 @@ public class MainActivity extends BridgeActivity {
|
|||||||
String versionName = obj.optString("versionName", "");
|
String versionName = obj.optString("versionName", "");
|
||||||
long versionCode = obj.optLong("versionCode", 0);
|
long versionCode = obj.optLong("versionCode", 0);
|
||||||
String apkUrl = obj.optString("apkUrl", "");
|
String apkUrl = obj.optString("apkUrl", "");
|
||||||
String sha256 = obj.optString("sha256", "");
|
|
||||||
String notes = obj.optString("notes", "");
|
String notes = obj.optString("notes", "");
|
||||||
boolean force = obj.optBoolean("force", false);
|
boolean force = obj.optBoolean("force", false);
|
||||||
|
|
||||||
if (versionName.isEmpty() || versionCode <= 0 || apkUrl.isEmpty() || sha256.isEmpty()) {
|
if (versionName.isEmpty() || versionCode <= 0 || apkUrl.isEmpty()) {
|
||||||
throw new IllegalStateException("Invalid version metadata payload");
|
throw new IllegalStateException("Invalid version metadata payload");
|
||||||
}
|
}
|
||||||
|
|
||||||
return new UpdateMetadata(versionName, versionCode, apkUrl, sha256, notes, force);
|
return new UpdateMetadata(versionName, versionCode, apkUrl, notes, force);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,9 @@ Required env:
|
|||||||
Optional env:
|
Optional env:
|
||||||
R2_PUBLIC_BASE_URL (example: https://downloads.dewemoji.com)
|
R2_PUBLIC_BASE_URL (example: https://downloads.dewemoji.com)
|
||||||
DEWEMOJI_APK_URL (default: https://dewemoji.com/downloads/dewemoji-latest.apk)
|
DEWEMOJI_APK_URL (default: https://dewemoji.com/downloads/dewemoji-latest.apk)
|
||||||
|
APK_VERSIONED_CACHE_CONTROL (default: public,max-age=31536000,immutable)
|
||||||
|
APK_LATEST_CACHE_CONTROL (default: no-store,max-age=0,must-revalidate)
|
||||||
|
APK_VERSION_JSON_CACHE_CONTROL (default: no-store,max-age=0,must-revalidate)
|
||||||
USAGE
|
USAGE
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,6 +85,9 @@ latest_key="apk/dewemoji-latest.apk"
|
|||||||
version_json_key="apk/version.json"
|
version_json_key="apk/version.json"
|
||||||
|
|
||||||
apk_url="${DEWEMOJI_APK_URL:-https://dewemoji.com/downloads/dewemoji-latest.apk}"
|
apk_url="${DEWEMOJI_APK_URL:-https://dewemoji.com/downloads/dewemoji-latest.apk}"
|
||||||
|
versioned_cache_control="${APK_VERSIONED_CACHE_CONTROL:-public,max-age=31536000,immutable}"
|
||||||
|
latest_cache_control="${APK_LATEST_CACHE_CONTROL:-no-store,max-age=0,must-revalidate}"
|
||||||
|
version_json_cache_control="${APK_VERSION_JSON_CACHE_CONTROL:-no-store,max-age=0,must-revalidate}"
|
||||||
version_json_path="${tmp_dir}/version.json"
|
version_json_path="${tmp_dir}/version.json"
|
||||||
"${MAKE_VERSION_SCRIPT}" \
|
"${MAKE_VERSION_SCRIPT}" \
|
||||||
--version-name "${version_name}" \
|
--version-name "${version_name}" \
|
||||||
@@ -94,13 +100,19 @@ version_json_path="${tmp_dir}/version.json"
|
|||||||
--out "${version_json_path}"
|
--out "${version_json_path}"
|
||||||
|
|
||||||
echo "== Upload versioned APK =="
|
echo "== Upload versioned APK =="
|
||||||
aws --endpoint-url "${endpoint}" s3 cp "${apk}" "s3://${R2_BUCKET}/${versioned_key}" --content-type application/vnd.android.package-archive
|
aws --endpoint-url "${endpoint}" s3 cp "${apk}" "s3://${R2_BUCKET}/${versioned_key}" \
|
||||||
|
--content-type application/vnd.android.package-archive \
|
||||||
|
--cache-control "${versioned_cache_control}"
|
||||||
|
|
||||||
echo "== Upload latest APK alias =="
|
echo "== Upload latest APK alias =="
|
||||||
aws --endpoint-url "${endpoint}" s3 cp "${apk}" "s3://${R2_BUCKET}/${latest_key}" --content-type application/vnd.android.package-archive
|
aws --endpoint-url "${endpoint}" s3 cp "${apk}" "s3://${R2_BUCKET}/${latest_key}" \
|
||||||
|
--content-type application/vnd.android.package-archive \
|
||||||
|
--cache-control "${latest_cache_control}"
|
||||||
|
|
||||||
echo "== Upload version metadata =="
|
echo "== Upload version metadata =="
|
||||||
aws --endpoint-url "${endpoint}" s3 cp "${version_json_path}" "s3://${R2_BUCKET}/${version_json_key}" --content-type application/json --cache-control no-store
|
aws --endpoint-url "${endpoint}" s3 cp "${version_json_path}" "s3://${R2_BUCKET}/${version_json_key}" \
|
||||||
|
--content-type application/json \
|
||||||
|
--cache-control "${version_json_cache_control}"
|
||||||
|
|
||||||
echo "Published to R2 bucket: ${R2_BUCKET}"
|
echo "Published to R2 bucket: ${R2_BUCKET}"
|
||||||
echo "Versioned APK key: ${versioned_key}"
|
echo "Versioned APK key: ${versioned_key}"
|
||||||
|
|||||||
Reference in New Issue
Block a user