capgo and adk autoupdates. card tags
This commit is contained in:
@@ -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')
|
||||
|
||||
}
|
||||
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user