Add APK release flow with R2 redirects and updater support

This commit is contained in:
Dwindi Ramadhana
2026-02-21 21:28:40 +07:00
parent 3d4a753be7
commit efc013f498
14 changed files with 865 additions and 120 deletions

View File

@@ -7,6 +7,7 @@ android/.idea/
android/local.properties
android/app/build/
android/build/
dist/
# logs
npm-debug.log*

View File

@@ -38,4 +38,5 @@
<!-- Permissions -->
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
</manifest>

View File

@@ -1,18 +1,73 @@
package com.dewemoji.app;
import android.app.AlertDialog;
import android.app.DownloadManager;
import android.content.ActivityNotFoundException;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.DialogInterface;
import android.content.Intent;
import android.content.IntentFilter;
import android.content.pm.PackageInfo;
import android.content.pm.PackageManager;
import android.database.Cursor;
import android.net.Uri;
import android.os.Build;
import android.os.Bundle;
import android.os.Environment;
import android.util.Log;
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.WindowInsetsCompat;
import androidx.core.view.WindowInsetsControllerCompat;
import com.getcapacitor.BridgeActivity;
import org.json.JSONObject;
import java.io.BufferedInputStream;
import java.io.ByteArrayOutputStream;
import java.io.InputStream;
import java.net.HttpURLConnection;
import java.net.URL;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.Locale;
public class MainActivity extends BridgeActivity {
private static final String TAG = "DewemojiUpdater";
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 READ_TIMEOUT_MS = 15_000;
@Nullable
private DownloadManager downloadManager;
private long activeDownloadId = -1L;
@Nullable
private String activeExpectedSha = null;
@Nullable
private BroadcastReceiver downloadReceiver = null;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
hideSystemBars();
downloadManager = (DownloadManager) getSystemService(Context.DOWNLOAD_SERVICE);
registerDownloadReceiver();
checkForUpdates(false);
}
@Override
protected void onDestroy() {
super.onDestroy();
if (downloadReceiver != null) {
unregisterReceiver(downloadReceiver);
downloadReceiver = null;
}
}
@Override
@@ -32,4 +87,271 @@ public class MainActivity extends BridgeActivity {
WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
);
}
private void checkForUpdates(boolean manual) {
new Thread(() -> {
try {
UpdateMetadata metadata = fetchVersionMetadata();
if (metadata == null) {
return;
}
long installedVersion = getInstalledVersionCode();
if (metadata.versionCode <= installedVersion) {
if (manual) {
runOnUiThread(() ->
Toast.makeText(this, "Dewemoji is up to date", Toast.LENGTH_SHORT).show()
);
}
return;
}
runOnUiThread(() -> showUpdateDialog(metadata));
} catch (Exception ex) {
Log.w(TAG, "Update check failed", ex);
if (manual) {
runOnUiThread(() ->
Toast.makeText(this, "Update check failed", Toast.LENGTH_SHORT).show()
);
}
}
}).start();
}
@Nullable
private UpdateMetadata fetchVersionMetadata() throws Exception {
HttpURLConnection conn = (HttpURLConnection) new URL(VERSION_URL).openConnection();
conn.setRequestMethod("GET");
conn.setConnectTimeout(CONNECT_TIMEOUT_MS);
conn.setReadTimeout(READ_TIMEOUT_MS);
conn.setRequestProperty("Accept", "application/json");
int code = conn.getResponseCode();
if (code < 200 || code >= 300) {
throw new IllegalStateException("Unexpected status " + code);
}
try (InputStream in = new BufferedInputStream(conn.getInputStream());
ByteArrayOutputStream out = new ByteArrayOutputStream()) {
byte[] buffer = new byte[8192];
int read;
while ((read = in.read(buffer)) != -1) {
out.write(buffer, 0, read);
}
String json = out.toString(StandardCharsets.UTF_8.name());
JSONObject obj = new JSONObject(json);
return UpdateMetadata.fromJson(obj);
} finally {
conn.disconnect();
}
}
private long getInstalledVersionCode() throws Exception {
PackageManager packageManager = getPackageManager();
PackageInfo info = packageManager.getPackageInfo(getPackageName(), 0);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
return info.getLongVersionCode();
}
return info.versionCode;
}
private void showUpdateDialog(UpdateMetadata metadata) {
StringBuilder message = new StringBuilder();
message.append("New version ").append(metadata.versionName).append(" is available.");
if (!metadata.notes.isEmpty()) {
message.append("\n\n").append(metadata.notes);
}
if (metadata.force) {
message.append("\n\nThis update is required.");
}
AlertDialog.Builder builder = new AlertDialog.Builder(this)
.setTitle("Update Dewemoji")
.setMessage(message.toString())
.setPositiveButton("Update", (dialog, which) -> startApkDownload(metadata))
.setCancelable(!metadata.force);
if (!metadata.force) {
builder.setNegativeButton("Later", null);
}
builder.show();
}
private void startApkDownload(UpdateMetadata metadata) {
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 {
Uri installUri = downloadUri;
if ("file".equals(downloadUri.getScheme())) {
installUri = FileProvider.getUriForFile(
this,
BuildConfig.APPLICATION_ID + ".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) {
showUpdateError("No installer found");
} catch (Exception ex) {
Log.e(TAG, "Failed to launch APK installer", ex);
showUpdateError("Cannot open installer");
}
}
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 {
final String versionName;
final long versionCode;
final String apkUrl;
final String sha256;
final String notes;
final boolean force;
private UpdateMetadata(
String versionName,
long versionCode,
String apkUrl,
String sha256,
String notes,
boolean force
) {
this.versionName = versionName;
this.versionCode = versionCode;
this.apkUrl = apkUrl;
this.sha256 = sha256;
this.notes = notes;
this.force = force;
}
static UpdateMetadata fromJson(JSONObject obj) {
String versionName = obj.optString("versionName", "");
long versionCode = obj.optLong("versionCode", 0);
String apkUrl = obj.optString("apkUrl", "");
String sha256 = obj.optString("sha256", "");
String notes = obj.optString("notes", "");
boolean force = obj.optBoolean("force", false);
if (versionName.isEmpty() || versionCode <= 0 || apkUrl.isEmpty() || sha256.isEmpty()) {
throw new IllegalStateException("Invalid version metadata payload");
}
return new UpdateMetadata(versionName, versionCode, apkUrl, sha256, notes, force);
}
}
}