|
|
|
@@ -0,0 +1,186 @@
|
|
|
|
|
package com.aggtaa.cardy;
|
|
|
|
|
|
|
|
|
|
import android.app.PendingIntent;
|
|
|
|
|
import android.content.Intent;
|
|
|
|
|
import android.content.pm.PackageInstaller;
|
|
|
|
|
import android.net.Uri;
|
|
|
|
|
import android.os.Build;
|
|
|
|
|
import android.provider.Settings;
|
|
|
|
|
import com.getcapacitor.JSObject;
|
|
|
|
|
import com.getcapacitor.Plugin;
|
|
|
|
|
import com.getcapacitor.PluginCall;
|
|
|
|
|
import com.getcapacitor.PluginMethod;
|
|
|
|
|
import com.getcapacitor.annotation.CapacitorPlugin;
|
|
|
|
|
import java.io.File;
|
|
|
|
|
import java.io.FileInputStream;
|
|
|
|
|
import java.io.FileOutputStream;
|
|
|
|
|
import java.io.IOException;
|
|
|
|
|
import java.io.InputStream;
|
|
|
|
|
import java.io.OutputStream;
|
|
|
|
|
import java.net.HttpURLConnection;
|
|
|
|
|
import java.net.URL;
|
|
|
|
|
import java.security.MessageDigest;
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Self-update of the whole app (outside of Play Store): downloads an APK, checks its SHA-256 and hands
|
|
|
|
|
* it to the system installer. Android asks the user to confirm unless it allows an update without user
|
|
|
|
|
* action (Android 12+, when this app installed itself last time).
|
|
|
|
|
*
|
|
|
|
|
* Events: "progress" { downloaded, total }, "status" { status: "installing" | "confirm" | "failed", message }.
|
|
|
|
|
* A successful install replaces and restarts the app, so there is no "done" event.
|
|
|
|
|
*/
|
|
|
|
|
@CapacitorPlugin(name = "ApkUpdater")
|
|
|
|
|
public class ApkUpdaterPlugin extends Plugin {
|
|
|
|
|
|
|
|
|
|
static final String ACTION_INSTALL_STATUS = "com.aggtaa.cardy.APK_INSTALL_STATUS";
|
|
|
|
|
private static ApkUpdaterPlugin instance;
|
|
|
|
|
private volatile boolean busy = false;
|
|
|
|
|
|
|
|
|
|
@Override
|
|
|
|
|
public void load() {
|
|
|
|
|
instance = this;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Whether the user allowed this app to install apps ("Install unknown apps"). */
|
|
|
|
|
@PluginMethod
|
|
|
|
|
public void canInstall(PluginCall call) {
|
|
|
|
|
JSObject result = new JSObject();
|
|
|
|
|
result.put("allowed", Build.VERSION.SDK_INT < 26 || getContext().getPackageManager().canRequestPackageInstalls());
|
|
|
|
|
call.resolve(result);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Opens the system screen where the user allows installing apps from Cardy. */
|
|
|
|
|
@PluginMethod
|
|
|
|
|
public void openInstallSettings(PluginCall call) {
|
|
|
|
|
if (Build.VERSION.SDK_INT >= 26) {
|
|
|
|
|
Intent intent = new Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES, Uri.parse("package:" + getContext().getPackageName()));
|
|
|
|
|
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
|
|
|
|
|
getContext().startActivity(intent);
|
|
|
|
|
}
|
|
|
|
|
call.resolve();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Downloads { url } , verifies { sha256 } and starts the installation. Resolves once installation has started. */
|
|
|
|
|
@PluginMethod
|
|
|
|
|
public void downloadAndInstall(PluginCall call) {
|
|
|
|
|
String url = call.getString("url");
|
|
|
|
|
String sha256 = call.getString("sha256");
|
|
|
|
|
if (url == null || sha256 == null) {
|
|
|
|
|
call.reject("url and sha256 are required");
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (busy) {
|
|
|
|
|
call.reject("An update is already in progress");
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
busy = true;
|
|
|
|
|
new Thread(() -> {
|
|
|
|
|
File apk = new File(getContext().getCacheDir(), "cardy-update.apk");
|
|
|
|
|
try {
|
|
|
|
|
download(url, apk);
|
|
|
|
|
String actual = sha256Hex(apk);
|
|
|
|
|
if (!actual.equalsIgnoreCase(sha256)) throw new IOException("Checksum mismatch");
|
|
|
|
|
install(apk);
|
|
|
|
|
call.resolve();
|
|
|
|
|
} catch (Exception e) {
|
|
|
|
|
busy = false;
|
|
|
|
|
apk.delete();
|
|
|
|
|
call.reject(e.getMessage() != null ? e.getMessage() : e.toString());
|
|
|
|
|
}
|
|
|
|
|
}).start();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private void download(String url, File target) throws IOException {
|
|
|
|
|
HttpURLConnection conn = (HttpURLConnection) new URL(url).openConnection();
|
|
|
|
|
conn.setConnectTimeout(20000);
|
|
|
|
|
conn.setReadTimeout(30000);
|
|
|
|
|
try {
|
|
|
|
|
if (conn.getResponseCode() != 200) throw new IOException("Download failed: HTTP " + conn.getResponseCode());
|
|
|
|
|
long total = conn.getContentLengthLong();
|
|
|
|
|
try (InputStream in = conn.getInputStream(); OutputStream out = new FileOutputStream(target)) {
|
|
|
|
|
byte[] buffer = new byte[64 * 1024];
|
|
|
|
|
long done = 0;
|
|
|
|
|
long lastReport = 0;
|
|
|
|
|
int n;
|
|
|
|
|
while ((n = in.read(buffer)) != -1) {
|
|
|
|
|
out.write(buffer, 0, n);
|
|
|
|
|
done += n;
|
|
|
|
|
if (done - lastReport >= 256 * 1024 || done == total) {
|
|
|
|
|
lastReport = done;
|
|
|
|
|
JSObject progress = new JSObject();
|
|
|
|
|
progress.put("downloaded", done);
|
|
|
|
|
progress.put("total", total);
|
|
|
|
|
notifyListeners("progress", progress);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
} finally {
|
|
|
|
|
conn.disconnect();
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static String sha256Hex(File file) throws Exception {
|
|
|
|
|
MessageDigest digest = MessageDigest.getInstance("SHA-256");
|
|
|
|
|
try (InputStream in = new FileInputStream(file)) {
|
|
|
|
|
byte[] buffer = new byte[64 * 1024];
|
|
|
|
|
int n;
|
|
|
|
|
while ((n = in.read(buffer)) != -1) digest.update(buffer, 0, n);
|
|
|
|
|
}
|
|
|
|
|
StringBuilder hex = new StringBuilder();
|
|
|
|
|
for (byte b : digest.digest()) hex.append(String.format("%02x", b));
|
|
|
|
|
return hex.toString();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private void install(File apk) throws IOException {
|
|
|
|
|
PackageInstaller installer = getContext().getPackageManager().getPackageInstaller();
|
|
|
|
|
PackageInstaller.SessionParams params = new PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL);
|
|
|
|
|
params.setAppPackageName(getContext().getPackageName());
|
|
|
|
|
if (Build.VERSION.SDK_INT >= 31) {
|
|
|
|
|
// Granted only when Android allows it (this app is the installer of record); otherwise it asks.
|
|
|
|
|
params.setRequireUserAction(PackageInstaller.SessionParams.USER_ACTION_NOT_REQUIRED);
|
|
|
|
|
}
|
|
|
|
|
int sessionId = installer.createSession(params);
|
|
|
|
|
try (PackageInstaller.Session session = installer.openSession(sessionId)) {
|
|
|
|
|
try (InputStream in = new FileInputStream(apk); OutputStream out = session.openWrite("cardy.apk", 0, apk.length())) {
|
|
|
|
|
byte[] buffer = new byte[64 * 1024];
|
|
|
|
|
int n;
|
|
|
|
|
while ((n = in.read(buffer)) != -1) out.write(buffer, 0, n);
|
|
|
|
|
session.fsync(out);
|
|
|
|
|
}
|
|
|
|
|
Intent intent = new Intent(getContext(), ApkInstallReceiver.class).setAction(ACTION_INSTALL_STATUS);
|
|
|
|
|
int flags = PendingIntent.FLAG_UPDATE_CURRENT | (Build.VERSION.SDK_INT >= 31 ? PendingIntent.FLAG_MUTABLE : 0);
|
|
|
|
|
PendingIntent pending = PendingIntent.getBroadcast(getContext(), sessionId, intent, flags);
|
|
|
|
|
session.commit(pending.getIntentSender());
|
|
|
|
|
}
|
|
|
|
|
JSObject status = new JSObject();
|
|
|
|
|
status.put("status", "installing");
|
|
|
|
|
notifyListeners("status", status);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** Called by ApkInstallReceiver with the installer's result. */
|
|
|
|
|
static void onInstallStatus(Intent intent) {
|
|
|
|
|
ApkUpdaterPlugin plugin = instance;
|
|
|
|
|
int status = intent.getIntExtra(PackageInstaller.EXTRA_STATUS, PackageInstaller.STATUS_FAILURE);
|
|
|
|
|
if (status == PackageInstaller.STATUS_PENDING_USER_ACTION) {
|
|
|
|
|
// Android wants the user to confirm: show its dialog.
|
|
|
|
|
Intent confirm = intent.getParcelableExtra(Intent.EXTRA_INTENT);
|
|
|
|
|
if (confirm != null && plugin != null) {
|
|
|
|
|
confirm.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
|
|
|
|
|
plugin.getContext().startActivity(confirm);
|
|
|
|
|
JSObject result = new JSObject();
|
|
|
|
|
result.put("status", "confirm");
|
|
|
|
|
plugin.notifyListeners("status", result);
|
|
|
|
|
}
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (plugin == null) return;
|
|
|
|
|
plugin.busy = false;
|
|
|
|
|
if (status != PackageInstaller.STATUS_SUCCESS) {
|
|
|
|
|
JSObject result = new JSObject();
|
|
|
|
|
result.put("status", "failed");
|
|
|
|
|
String message = intent.getStringExtra(PackageInstaller.EXTRA_STATUS_MESSAGE);
|
|
|
|
|
result.put("message", message != null ? message : "Installation failed (" + status + ")");
|
|
|
|
|
plugin.notifyListeners("status", result);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|