capgo and adk autoupdates. card tags

This commit is contained in:
azykov@mail.ru
2026-09-29 19:36:08 +03:00
parent 1981d976eb
commit b6fb2fe40f
43 changed files with 1329 additions and 54 deletions
+35 -2
View File
@@ -1,5 +1,20 @@
apply plugin: 'com.android.application'
// ---- Cardy: versioning and signing ----
// versionCode from the build time (UTC): yyDDDHHmm (year, day of year, time), e.g. 262721430.
// Grows with every build and stays below Android's limit (2100000000) until 2099.
// Override with -PcardyVersionCode=... (the APK publishing script passes the value it records).
def cardyVersionCode = project.findProperty('cardyVersionCode') ?: new Date().format('yyDDDHHmm', TimeZone.getTimeZone('UTC'))
def cardyVersionName = project.findProperty('cardyVersionName') ?: '1.0'
// Release key: android/keystore.properties (not in git) with storeFile, storePassword, keyAlias, keyPassword.
// Debug builds use the same key so that Android Studio builds and published APKs can replace each other.
def keystoreProps = new Properties()
def keystoreFile = rootProject.file('keystore.properties')
if (keystoreFile.exists()) keystoreFile.withInputStream { keystoreProps.load(it) }
def hasReleaseKey = ['storeFile', 'storePassword', 'keyAlias', 'keyPassword'].every { keystoreProps.getProperty(it)?.trim() }
android {
namespace = "com.aggtaa.cardy"
compileSdk = rootProject.ext.compileSdkVersion
@@ -7,8 +22,8 @@ android {
applicationId "com.aggtaa.cardy"
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 1
versionName "1.0"
versionCode cardyVersionCode as Integer
versionName cardyVersionName
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
aaptOptions {
// Files and dirs to omit from the packaged assets dir, modified to accommodate modern web apps.
@@ -16,14 +31,32 @@ android {
ignoreAssetsPattern = '!.svn:!.git:!.ds_store:!*.scc:.*:!CVS:!thumbs.db:!picasa.ini:!*~'
}
}
signingConfigs {
if (hasReleaseKey) {
cardy {
storeFile file(keystoreProps.getProperty('storeFile').trim())
storePassword keystoreProps.getProperty('storePassword')
keyAlias keystoreProps.getProperty('keyAlias').trim()
keyPassword keystoreProps.getProperty('keyPassword')
}
}
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
if (hasReleaseKey) signingConfig signingConfigs.cardy
}
debug {
if (hasReleaseKey) signingConfig signingConfigs.cardy
}
}
}
if (!hasReleaseKey) {
logger.warn('Cardy: android/keystore.properties is missing or incomplete: debug builds use the debug key, release builds are unsigned.')
}
repositories {
flatDir{
dirs '../capacitor-cordova-android-plugins/src/main/libs', 'libs'
@@ -11,6 +11,7 @@ apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
dependencies {
implementation project(':capacitor-app')
implementation project(':capacitor-preferences')
implementation project(':capgo-capacitor-updater')
}
@@ -0,0 +1,37 @@
{
"version": 3,
"artifactType": {
"type": "APK",
"kind": "Directory"
},
"applicationId": "com.aggtaa.cardy",
"variantName": "release",
"elements": [
{
"type": "SINGLE",
"filters": [],
"attributes": [],
"versionCode": 1,
"versionName": "1.0",
"outputFile": "app-release.apk"
}
],
"elementType": "File",
"baselineProfiles": [
{
"minApi": 28,
"maxApi": 30,
"baselineProfiles": [
"baselineProfiles/1/app-release.dm"
]
},
{
"minApi": 31,
"maxApi": 2147483647,
"baselineProfiles": [
"baselineProfiles/0/app-release.dm"
]
}
],
"minSdkVersionForDexing": 24
}
@@ -32,6 +32,11 @@
</activity>
<!-- Results of self-updates via PackageInstaller (ApkUpdaterPlugin). -->
<receiver
android:name=".ApkInstallReceiver"
android:exported="false" />
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
@@ -49,4 +54,7 @@
<!-- Scanning key QR codes and card barcodes (getUserMedia in the WebView). -->
<uses-permission android:name="android.permission.CAMERA" />
<uses-feature android:name="android.hardware.camera" android:required="false" />
<!-- Self-update: install newer APKs of this app downloaded from the Cardy server. -->
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<uses-permission android:name="android.permission.UPDATE_PACKAGES_WITHOUT_USER_ACTION" />
</manifest>
@@ -0,0 +1,15 @@
package com.aggtaa.cardy;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
/** Receives PackageInstaller results for self-updates (see ApkUpdaterPlugin). */
public class ApkInstallReceiver extends BroadcastReceiver {
@Override
public void onReceive(Context context, Intent intent) {
if (ApkUpdaterPlugin.ACTION_INSTALL_STATUS.equals(intent.getAction())) {
ApkUpdaterPlugin.onInstallStatus(intent);
}
}
}
@@ -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);
}
}
}
@@ -7,6 +7,7 @@ public class MainActivity extends BridgeActivity {
@Override
public void onCreate(Bundle savedInstanceState) {
registerPlugin(ScreenBrightnessPlugin.class);
registerPlugin(ApkUpdaterPlugin.class);
super.onCreate(savedInstanceState);
}
}