mirror of
https://github.com/bitsocialnet/5chan.git
synced 2026-08-03 07:41:04 +02:00
fix: remove Android self-install updater (#1125)
* fix: remove Android self-install updater * fix: open Android updates in native browser
This commit is contained in:
@@ -10,6 +10,7 @@ android {
|
||||
apply from: "../capacitor-cordova-android-plugins/cordova.variables.gradle"
|
||||
dependencies {
|
||||
implementation project(':capacitor-app')
|
||||
implementation project(':capacitor-browser')
|
||||
implementation project(':capacitor-status-bar')
|
||||
implementation project(':capawesome-capacitor-android-edge-to-edge-support')
|
||||
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||
</manifest>
|
||||
@@ -1,207 +0,0 @@
|
||||
package fivechan.android;
|
||||
|
||||
import android.app.Activity;
|
||||
import android.content.pm.ApplicationInfo;
|
||||
import android.content.Intent;
|
||||
import android.content.pm.PackageManager;
|
||||
import android.net.Uri;
|
||||
import android.os.Build;
|
||||
import android.provider.Settings;
|
||||
import android.util.Log;
|
||||
|
||||
import androidx.core.content.FileProvider;
|
||||
|
||||
import com.getcapacitor.Plugin;
|
||||
import com.getcapacitor.PluginCall;
|
||||
import com.getcapacitor.PluginMethod;
|
||||
import com.getcapacitor.annotation.CapacitorPlugin;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
import java.net.URI;
|
||||
import java.util.Locale;
|
||||
|
||||
import okhttp3.OkHttpClient;
|
||||
import okhttp3.Request;
|
||||
import okhttp3.Response;
|
||||
|
||||
@CapacitorPlugin(name = "AppUpdater")
|
||||
public class AppUpdaterPlugin extends Plugin {
|
||||
private static final String TAG = "AppUpdaterPlugin";
|
||||
private static final String EMULATOR_HOST = "10.0.2.2";
|
||||
private static final String GITHUB_HOST = "github.com";
|
||||
private static final String GITHUB_CONTENT_HOST = "githubusercontent.com";
|
||||
|
||||
@PluginMethod
|
||||
public void downloadAndInstallUpdate(PluginCall call) {
|
||||
final String url = sanitizeUrl(call.getString("url"));
|
||||
final String fileName = sanitizeFileName(call.getString("fileName"));
|
||||
|
||||
if (url == null) {
|
||||
call.reject("Update url is required");
|
||||
return;
|
||||
}
|
||||
|
||||
new Thread(
|
||||
() -> {
|
||||
try {
|
||||
File apkFile = downloadApk(url, fileName);
|
||||
Activity activity = getActivity();
|
||||
if (activity == null) {
|
||||
call.reject("Activity unavailable");
|
||||
return;
|
||||
}
|
||||
|
||||
activity.runOnUiThread(() -> openInstaller(call, activity, apkFile));
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Failed to download update", e);
|
||||
call.reject("Failed to download update: " + e.getMessage(), e);
|
||||
}
|
||||
})
|
||||
.start();
|
||||
}
|
||||
|
||||
private boolean isDebugBuild() {
|
||||
return (getContext().getApplicationInfo().flags & ApplicationInfo.FLAG_DEBUGGABLE) != 0;
|
||||
}
|
||||
|
||||
static boolean isAllowedDownloadUrl(
|
||||
String value, boolean allowGitHubContentHosts, boolean allowDebugHosts) {
|
||||
if (value == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
String sanitized = value.trim();
|
||||
if (sanitized.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
final URI uri;
|
||||
try {
|
||||
uri = URI.create(sanitized);
|
||||
} catch (IllegalArgumentException exception) {
|
||||
return false;
|
||||
}
|
||||
|
||||
String scheme = uri.getScheme();
|
||||
String host = uri.getHost();
|
||||
|
||||
if (scheme == null || host == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
String normalizedScheme = scheme.toLowerCase(Locale.ROOT);
|
||||
String normalizedHost = host.toLowerCase(Locale.ROOT);
|
||||
|
||||
if ("https".equals(normalizedScheme)
|
||||
&& (GITHUB_HOST.equals(normalizedHost)
|
||||
|| (allowGitHubContentHosts
|
||||
&& (GITHUB_CONTENT_HOST.equals(normalizedHost)
|
||||
|| normalizedHost.endsWith("." + GITHUB_CONTENT_HOST))))) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (allowDebugHosts
|
||||
&& ("http".equals(normalizedScheme) || "https".equals(normalizedScheme))
|
||||
&& (EMULATOR_HOST.equals(normalizedHost)
|
||||
|| "127.0.0.1".equals(normalizedHost)
|
||||
|| "localhost".equals(normalizedHost))) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private String sanitizeUrl(String value) {
|
||||
if (!isAllowedDownloadUrl(value, false, isDebugBuild())) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
private String sanitizeFileName(String value) {
|
||||
if (value == null || value.trim().isEmpty()) {
|
||||
return "5chan-update.apk";
|
||||
}
|
||||
|
||||
String sanitized = value.trim().replace("\\", "/");
|
||||
int slashIndex = sanitized.lastIndexOf('/');
|
||||
String fileName = slashIndex >= 0 ? sanitized.substring(slashIndex + 1) : sanitized;
|
||||
return fileName.toLowerCase().endsWith(".apk") ? fileName : fileName + ".apk";
|
||||
}
|
||||
|
||||
private File downloadApk(String url, String fileName) throws IOException {
|
||||
File updatesDirectory = new File(getContext().getCacheDir(), "app-updates");
|
||||
if (!updatesDirectory.exists() && !updatesDirectory.mkdirs()) {
|
||||
throw new IOException("Could not create update directory");
|
||||
}
|
||||
|
||||
File apkFile = new File(updatesDirectory, fileName);
|
||||
File tempFile = new File(updatesDirectory, fileName + ".download");
|
||||
if (tempFile.exists() && !tempFile.delete()) {
|
||||
throw new IOException("Could not replace pending update download");
|
||||
}
|
||||
|
||||
OkHttpClient client = new OkHttpClient();
|
||||
Request request = new Request.Builder().url(url).build();
|
||||
|
||||
try (Response response = client.newCall(request).execute()) {
|
||||
if (!response.isSuccessful() || response.body() == null) {
|
||||
throw new IOException("Unexpected response " + response.code());
|
||||
}
|
||||
|
||||
String finalUrl = response.request().url().toString();
|
||||
if (!isAllowedDownloadUrl(finalUrl, true, isDebugBuild())) {
|
||||
throw new IOException("Unexpected redirected download host");
|
||||
}
|
||||
|
||||
try (InputStream inputStream = response.body().byteStream();
|
||||
OutputStream outputStream = new java.io.FileOutputStream(tempFile)) {
|
||||
byte[] buffer = new byte[8192];
|
||||
int read;
|
||||
while ((read = inputStream.read(buffer)) != -1) {
|
||||
outputStream.write(buffer, 0, read);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (apkFile.exists() && !apkFile.delete()) {
|
||||
throw new IOException("Could not replace existing update package");
|
||||
}
|
||||
|
||||
if (!tempFile.renameTo(apkFile)) {
|
||||
throw new IOException("Could not finalize update package");
|
||||
}
|
||||
|
||||
return apkFile;
|
||||
}
|
||||
|
||||
private void openInstaller(PluginCall call, Activity activity, File apkFile) {
|
||||
PackageManager packageManager = activity.getPackageManager();
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && !packageManager.canRequestPackageInstalls()) {
|
||||
Intent settingsIntent = new Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES, Uri.parse("package:" + activity.getPackageName()));
|
||||
settingsIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
|
||||
activity.startActivity(settingsIntent);
|
||||
call.reject("Allow app installs for 5chan, then tap update again.");
|
||||
return;
|
||||
}
|
||||
|
||||
Uri apkUri = FileProvider.getUriForFile(activity, activity.getPackageName() + ".fileprovider", apkFile);
|
||||
Intent installIntent = new Intent(Intent.ACTION_VIEW);
|
||||
installIntent.setDataAndType(apkUri, "application/vnd.android.package-archive");
|
||||
installIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK);
|
||||
installIntent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION);
|
||||
|
||||
try {
|
||||
activity.startActivity(installIntent);
|
||||
call.resolve();
|
||||
} catch (Exception e) {
|
||||
Log.e(TAG, "Failed to open installer", e);
|
||||
call.reject("Failed to open installer: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,6 @@ public class MainActivity extends BridgeActivity {
|
||||
@Override
|
||||
public void onCreate(Bundle savedInstanceState) {
|
||||
// Custom plugins must be registered before bridge initialization.
|
||||
registerPlugin(AppUpdaterPlugin.class);
|
||||
registerPlugin(FileUploaderPlugin.class);
|
||||
registerPlugin(StatusBarPlugin.class);
|
||||
super.onCreate(savedInstanceState);
|
||||
|
||||
@@ -3,6 +3,10 @@
|
||||
"pkg": "@capacitor/app",
|
||||
"classpath": "com.capacitorjs.plugins.app.AppPlugin"
|
||||
},
|
||||
{
|
||||
"pkg": "@capacitor/browser",
|
||||
"classpath": "com.capacitorjs.plugins.browser.BrowserPlugin"
|
||||
},
|
||||
{
|
||||
"pkg": "@capacitor/status-bar",
|
||||
"classpath": "com.capacitorjs.plugins.statusbar.StatusBarPlugin"
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
package fivechan.android;
|
||||
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
|
||||
import org.junit.Test;
|
||||
|
||||
public class AppUpdaterPluginTest {
|
||||
|
||||
@Test
|
||||
public void initialDownload_requiresGithubHost() {
|
||||
assertTrue(
|
||||
AppUpdaterPlugin.isAllowedDownloadUrl(
|
||||
"https://github.com/bitsocialnet/5chan/releases/download/v0.7.3/5chan.apk",
|
||||
false,
|
||||
false));
|
||||
assertFalse(
|
||||
AppUpdaterPlugin.isAllowedDownloadUrl(
|
||||
"https://objects.githubusercontent.com/github-production-release-asset.apk",
|
||||
false,
|
||||
false));
|
||||
assertFalse(
|
||||
AppUpdaterPlugin.isAllowedDownloadUrl(
|
||||
"https://evil.example/5chan.apk", false, false));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void redirectedDownload_allowsGithubContentHostsOnly() {
|
||||
assertTrue(
|
||||
AppUpdaterPlugin.isAllowedDownloadUrl(
|
||||
"https://objects.githubusercontent.com/github-production-release-asset.apk",
|
||||
true,
|
||||
false));
|
||||
assertTrue(
|
||||
AppUpdaterPlugin.isAllowedDownloadUrl(
|
||||
"https://release-assets.githubusercontent.com/file.apk", true, false));
|
||||
assertFalse(
|
||||
AppUpdaterPlugin.isAllowedDownloadUrl(
|
||||
"https://githubusercontent.evil.example/file.apk", true, false));
|
||||
assertFalse(
|
||||
AppUpdaterPlugin.isAllowedDownloadUrl(
|
||||
"https://evil.example/file.apk", true, false));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void debugDownloads_allowOnlyLocalHosts() {
|
||||
assertTrue(
|
||||
AppUpdaterPlugin.isAllowedDownloadUrl(
|
||||
"http://10.0.2.2:56405/5chan.apk", false, true));
|
||||
assertTrue(
|
||||
AppUpdaterPlugin.isAllowedDownloadUrl(
|
||||
"https://localhost:56405/5chan.apk", false, true));
|
||||
assertFalse(
|
||||
AppUpdaterPlugin.isAllowedDownloadUrl(
|
||||
"http://192.168.1.8:56405/5chan.apk", false, true));
|
||||
assertFalse(
|
||||
AppUpdaterPlugin.isAllowedDownloadUrl(
|
||||
"http://10.0.2.2:56405/5chan.apk", false, false));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void malformedOrUnsupportedUrls_areRejected() {
|
||||
assertFalse(AppUpdaterPlugin.isAllowedDownloadUrl(null, true, true));
|
||||
assertFalse(AppUpdaterPlugin.isAllowedDownloadUrl("not a url", true, true));
|
||||
assertFalse(
|
||||
AppUpdaterPlugin.isAllowedDownloadUrl(
|
||||
"ftp://github.com/bitsocialnet/5chan/file.apk", true, true));
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,9 @@ project(':capacitor-android').projectDir = new File('../node_modules/@capacitor/
|
||||
include ':capacitor-app'
|
||||
project(':capacitor-app').projectDir = new File('../node_modules/@capacitor/app/android')
|
||||
|
||||
include ':capacitor-browser'
|
||||
project(':capacitor-browser').projectDir = new File('../node_modules/@capacitor/browser/android')
|
||||
|
||||
include ':capacitor-status-bar'
|
||||
project(':capacitor-status-bar').projectDir = new File('../node_modules/@capacitor/status-bar/android')
|
||||
|
||||
|
||||
+1
-1
@@ -10,6 +10,7 @@
|
||||
"dependencies": {
|
||||
"@bitsocial/bitsocial-react-hooks": "0.1.6",
|
||||
"@capacitor/app": "7.0.1",
|
||||
"@capacitor/browser": "7.0.5",
|
||||
"@capacitor/status-bar": "7.0.1",
|
||||
"@capawesome/capacitor-android-edge-to-edge-support": "7.2.2",
|
||||
"@chenglou/pretext": "0.0.5",
|
||||
@@ -85,7 +86,6 @@
|
||||
"electron:before": "corepack yarn electron:before:delete-data",
|
||||
"electron:before:delete-data": "node scripts/clear-local-pkc-data.mjs",
|
||||
"test:update:e2e:electron": "node scripts/run-electron-app-update-e2e.mjs",
|
||||
"test:update:e2e:android": "node scripts/run-android-app-update-e2e.mjs",
|
||||
"test:thread-auto-update:e2e": "node scripts/run-thread-auto-update-e2e.mjs --start-dev",
|
||||
"android:build:icons": "cordova-res android --skip-config --copy --resources /tmp/bitsocial-react-android-icons --icon-source ./android/icons/icon.png --splash-source ./android/icons/splash.png --icon-foreground-source ./android/icons/icon-foreground.png --icon-background-source '#ffffee'",
|
||||
"knip": "knip --production --include dependencies,unlisted,binaries --no-progress",
|
||||
|
||||
@@ -1,240 +0,0 @@
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { createTempWorkspace, logStep, repoRoot, runCommand, sleep, startFixtureServer, waitFor } from './app-update-e2e-helpers.mjs';
|
||||
|
||||
const OLD_VERSION = '0.7.1';
|
||||
const NEW_VERSION = '0.7.3';
|
||||
const OLD_VERSION_CODE = '701';
|
||||
const NEW_VERSION_CODE = '703';
|
||||
const PACKAGE_NAME = 'fivechan.android';
|
||||
const ACTIVITY_NAME = `${PACKAGE_NAME}/.MainActivity`;
|
||||
const AVD_NAME = 'fivechan-test-api35';
|
||||
const SETTINGS_HASH = '#/all/settings#interface-settings';
|
||||
|
||||
const getAndroidSdkRoot = () => process.env.ANDROID_HOME || process.env.ANDROID_SDK_ROOT;
|
||||
|
||||
const getSdkToolPath = (relativePath) => {
|
||||
const sdkRoot = getAndroidSdkRoot();
|
||||
if (!sdkRoot) {
|
||||
throw new Error('ANDROID_HOME or ANDROID_SDK_ROOT must be set');
|
||||
}
|
||||
|
||||
return path.join(sdkRoot, relativePath);
|
||||
};
|
||||
|
||||
const adb = async (args, options = {}) => runCommand(getSdkToolPath('platform-tools/adb'), args, options);
|
||||
|
||||
const buildAndroidDebugApk = async ({ version, versionCode, fixturePort }) => {
|
||||
await runCommand('corepack', ['yarn', 'build'], {
|
||||
env: {
|
||||
VITE_APP_VERSION: version,
|
||||
VITE_APP_UPDATE_ALLOWED_DOWNLOAD_HOSTS: '10.0.2.2',
|
||||
VITE_APP_UPDATE_RELEASE_API_URL: `http://10.0.2.2:${fixturePort}/releases/latest`,
|
||||
VITE_E2E_START_HASH: SETTINGS_HASH,
|
||||
},
|
||||
});
|
||||
await runCommand('npx', ['cap', 'sync', 'android']);
|
||||
await runCommand('./gradlew', ['assembleDebug', `-PAPP_VERSION_CODE=${versionCode}`, `-PAPP_VERSION_NAME=${version}`], {
|
||||
cwd: path.join(repoRoot, 'android'),
|
||||
});
|
||||
|
||||
return path.join(repoRoot, 'android', 'app', 'build', 'outputs', 'apk', 'debug', 'app-debug.apk');
|
||||
};
|
||||
|
||||
const ensureEmulatorRunning = async () => {
|
||||
const devicesOutput = await adb(['devices'], { captureOutput: true });
|
||||
const existingEmulator = devicesOutput.stdout
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.find((line) => line.startsWith('emulator-') && line.endsWith('\tdevice'));
|
||||
|
||||
if (existingEmulator) {
|
||||
return existingEmulator.split('\t')[0];
|
||||
}
|
||||
|
||||
const avdManagerPath = getSdkToolPath('cmdline-tools/latest/bin/avdmanager');
|
||||
const emulatorPath = getSdkToolPath('emulator/emulator');
|
||||
const avdList = await runCommand(avdManagerPath, ['list', 'avd'], {
|
||||
captureOutput: true,
|
||||
});
|
||||
|
||||
if (!avdList.stdout.includes(`Name: ${AVD_NAME}`)) {
|
||||
await runCommand('/bin/sh', ['-lc', `echo "no" | "${avdManagerPath}" create avd --name "${AVD_NAME}" --package "system-images;android-35;google_apis;arm64-v8a" --device pixel_6 --force`]);
|
||||
}
|
||||
|
||||
const emulatorLogPath = path.join(os.tmpdir(), `fivechan-emulator-${Date.now()}.log`);
|
||||
logStep(`starting emulator, log: ${emulatorLogPath}`);
|
||||
runCommand('/bin/sh', ['-lc', `"${emulatorPath}" -avd "${AVD_NAME}" -no-boot-anim -no-snapshot-save -netdelay none -netspeed full > "${emulatorLogPath}" 2>&1 &`]);
|
||||
|
||||
await adb(['wait-for-device']);
|
||||
await waitFor(
|
||||
async () => {
|
||||
const result = await adb(['shell', 'getprop', 'sys.boot_completed'], {
|
||||
captureOutput: true,
|
||||
});
|
||||
return result.stdout.replace(/\s+/g, '') === '1';
|
||||
},
|
||||
{
|
||||
timeoutMs: 180000,
|
||||
intervalMs: 2000,
|
||||
description: 'the Android emulator to finish booting',
|
||||
},
|
||||
);
|
||||
|
||||
await adb(['shell', 'settings', 'put', 'global', 'window_animation_scale', '0']);
|
||||
await adb(['shell', 'settings', 'put', 'global', 'transition_animation_scale', '0']);
|
||||
await adb(['shell', 'settings', 'put', 'global', 'animator_duration_scale', '0']);
|
||||
|
||||
const refreshedDevicesOutput = await adb(['devices'], { captureOutput: true });
|
||||
const emulatorLine = refreshedDevicesOutput.stdout
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.find((line) => line.startsWith('emulator-') && line.endsWith('\tdevice'));
|
||||
|
||||
if (!emulatorLine) {
|
||||
throw new Error('Could not determine the running emulator serial');
|
||||
}
|
||||
|
||||
return emulatorLine.split('\t')[0];
|
||||
};
|
||||
|
||||
const readUiDump = async (serial) => {
|
||||
await adb(['-s', serial, 'shell', 'uiautomator', 'dump', '/sdcard/window_dump.xml']);
|
||||
const dumpResult = await adb(['-s', serial, 'shell', 'cat', '/sdcard/window_dump.xml'], {
|
||||
captureOutput: true,
|
||||
});
|
||||
return dumpResult.stdout;
|
||||
};
|
||||
|
||||
const findNodeBounds = (dumpXml, matcher) => {
|
||||
const nodePattern = /<node\b([^>]+?)\/>/g;
|
||||
|
||||
for (const match of dumpXml.matchAll(nodePattern)) {
|
||||
const attributes = match[1];
|
||||
const textMatch = attributes.match(/\btext="([^"]*)"/);
|
||||
const contentDescMatch = attributes.match(/\bcontent-desc="([^"]*)"/);
|
||||
const boundsMatch = attributes.match(/\bbounds="\[(\d+),(\d+)\]\[(\d+),(\d+)\]"/);
|
||||
const clickableMatch = attributes.match(/\bclickable="([^"]*)"/);
|
||||
const text = textMatch?.[1] || contentDescMatch?.[1] || '';
|
||||
|
||||
if (!boundsMatch || !matcher(text, clickableMatch?.[1] === 'true')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const [, left, top, right, bottom] = boundsMatch.map(Number);
|
||||
return {
|
||||
x: Math.floor((left + right) / 2),
|
||||
y: Math.floor((top + bottom) / 2),
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
const tapText = async (serial, { exactText, pattern, timeoutMs = 120000 }) => {
|
||||
const bounds = await waitFor(
|
||||
async () => {
|
||||
const dumpXml = await readUiDump(serial);
|
||||
return findNodeBounds(dumpXml, (text, clickable) => clickable && (exactText ? text === exactText : pattern.test(text)));
|
||||
},
|
||||
{
|
||||
timeoutMs,
|
||||
intervalMs: 2000,
|
||||
description: `UI text ${exactText || pattern}`,
|
||||
},
|
||||
);
|
||||
|
||||
await adb(['-s', serial, 'shell', 'input', 'tap', `${bounds.x}`, `${bounds.y}`]);
|
||||
};
|
||||
|
||||
const waitForText = async (serial, matcher, description) =>
|
||||
waitFor(
|
||||
async () => {
|
||||
const dumpXml = await readUiDump(serial);
|
||||
if (typeof matcher === 'string') {
|
||||
return dumpXml.includes(`text="${matcher}"`) || dumpXml.includes(`content-desc="${matcher}"`);
|
||||
}
|
||||
|
||||
return matcher.test(dumpXml);
|
||||
},
|
||||
{
|
||||
timeoutMs: 120000,
|
||||
intervalMs: 2000,
|
||||
description,
|
||||
},
|
||||
);
|
||||
|
||||
const waitForInstalledVersion = async (serial, version) =>
|
||||
waitFor(
|
||||
async () => {
|
||||
const packageInfo = await adb(['-s', serial, 'shell', 'dumpsys', 'package', PACKAGE_NAME], {
|
||||
captureOutput: true,
|
||||
});
|
||||
return packageInfo.stdout.includes(`versionName=${version}`);
|
||||
},
|
||||
{
|
||||
timeoutMs: 180000,
|
||||
intervalMs: 3000,
|
||||
description: `installed package version ${version}`,
|
||||
},
|
||||
);
|
||||
|
||||
const main = async () => {
|
||||
const workspace = await createTempWorkspace('android-update-e2e');
|
||||
const fixtureServer = await startFixtureServer();
|
||||
|
||||
try {
|
||||
const oldApkSourcePath = await buildAndroidDebugApk({
|
||||
version: OLD_VERSION,
|
||||
versionCode: OLD_VERSION_CODE,
|
||||
fixturePort: fixtureServer.port,
|
||||
});
|
||||
const oldApkPath = path.join(workspace, `5chan-v${OLD_VERSION}.apk`);
|
||||
await fs.promises.copyFile(oldApkSourcePath, oldApkPath);
|
||||
|
||||
const newApkSourcePath = await buildAndroidDebugApk({
|
||||
version: NEW_VERSION,
|
||||
versionCode: NEW_VERSION_CODE,
|
||||
fixturePort: fixtureServer.port,
|
||||
});
|
||||
const newApkPath = path.join(workspace, `5chan-v${NEW_VERSION}.apk`);
|
||||
await fs.promises.copyFile(newApkSourcePath, newApkPath);
|
||||
|
||||
fixtureServer.setRelease(NEW_VERSION, [
|
||||
{
|
||||
name: path.basename(newApkPath),
|
||||
filePath: newApkPath,
|
||||
},
|
||||
]);
|
||||
|
||||
const serial = await ensureEmulatorRunning();
|
||||
await adb(['-s', serial, 'uninstall', PACKAGE_NAME]).catch(() => undefined);
|
||||
await adb(['-s', serial, 'install', '-r', oldApkPath]);
|
||||
await adb(['-s', serial, 'shell', 'appops', 'set', PACKAGE_NAME, 'REQUEST_INSTALL_PACKAGES', 'allow']);
|
||||
await adb(['-s', serial, 'shell', 'am', 'start', '-n', ACTIVITY_NAME]);
|
||||
|
||||
await waitForText(serial, 'Check', 'the settings update check button');
|
||||
await tapText(serial, { exactText: 'Check' });
|
||||
await waitForText(serial, 'Download', 'the download button after update detection');
|
||||
await waitForText(serial, `v${NEW_VERSION}`, 'the release version link text');
|
||||
await tapText(serial, { exactText: 'Download' });
|
||||
await tapText(serial, {
|
||||
pattern: /^(Install|Update)$/i,
|
||||
timeoutMs: 120000,
|
||||
});
|
||||
await waitForInstalledVersion(serial, NEW_VERSION);
|
||||
|
||||
await adb(['-s', serial, 'shell', 'am', 'force-stop', PACKAGE_NAME]);
|
||||
await adb(['-s', serial, 'shell', 'am', 'start', '-n', ACTIVITY_NAME]);
|
||||
await waitForText(serial, `v${NEW_VERSION}`, 'the relaunched app version text');
|
||||
|
||||
logStep(`android update e2e passed on ${serial}: package ${PACKAGE_NAME} updated to ${NEW_VERSION}`);
|
||||
} finally {
|
||||
await sleep(1000);
|
||||
await fixtureServer.close();
|
||||
await fs.promises.rm(workspace, { recursive: true, force: true }).catch(() => undefined);
|
||||
}
|
||||
};
|
||||
|
||||
await main();
|
||||
@@ -250,8 +250,6 @@ describe('InterfaceSettings', () => {
|
||||
availableUpdate: {
|
||||
runtime: 'android',
|
||||
targetVersion: '9.9.9',
|
||||
assetName: '5chan-9.9.9.apk',
|
||||
downloadUrl: 'https://github.com/bitsocialnet/5chan/releases/download/v9.9.9/5chan-9.9.9.apk',
|
||||
releaseUrl: 'https://github.com/bitsocialnet/5chan/releases/tag/v9.9.9',
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const testState = vi.hoisted(() => ({
|
||||
androidDownloadAndInstallUpdateMock: vi.fn(),
|
||||
capacitorPlatform: 'web',
|
||||
browserOpenMock: vi.fn(),
|
||||
electronDownloadAndInstallUpdateMock: vi.fn(),
|
||||
electronGetPlatformMock: vi.fn(),
|
||||
fetchMock: vi.fn(),
|
||||
openMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('@capacitor/core', () => ({
|
||||
@@ -14,9 +15,9 @@ vi.mock('@capacitor/core', () => ({
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../../plugins/app-updater', () => ({
|
||||
default: {
|
||||
downloadAndInstallUpdate: (options: unknown) => testState.androidDownloadAndInstallUpdateMock(options),
|
||||
vi.mock('@capacitor/browser', () => ({
|
||||
Browser: {
|
||||
open: (options: unknown) => testState.browserOpenMock(options),
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -38,13 +39,16 @@ const loadModule = async () => {
|
||||
describe('app-update', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
testState.androidDownloadAndInstallUpdateMock.mockReset();
|
||||
testState.capacitorPlatform = 'web';
|
||||
testState.browserOpenMock.mockReset();
|
||||
testState.electronDownloadAndInstallUpdateMock.mockReset();
|
||||
testState.electronGetPlatformMock.mockReset();
|
||||
testState.fetchMock.mockReset();
|
||||
testState.openMock.mockReset();
|
||||
testState.openMock.mockReturnValue({ closed: false });
|
||||
vi.stubEnv('VITE_APP_VERSION', '0.8.1');
|
||||
vi.stubGlobal('fetch', testState.fetchMock);
|
||||
vi.stubGlobal('open', testState.openMock);
|
||||
window.electronApi = undefined;
|
||||
Object.defineProperty(navigator, 'serviceWorker', {
|
||||
configurable: true,
|
||||
@@ -103,14 +107,13 @@ describe('app-update', () => {
|
||||
applyAvailableAppUpdate({
|
||||
runtime: 'android',
|
||||
targetVersion: '9.9.9',
|
||||
assetName: '5chan-9.9.9.apk',
|
||||
downloadUrl: 'https://github.com/bitsocialnet/5chan/releases/download/v9.9.9/5chan-9.9.9.apk',
|
||||
releaseUrl: 'https://github.com/bitsocialnet/5chan/releases/tag/v9.9.9',
|
||||
}),
|
||||
).rejects.toThrow('App updates are disabled for this build');
|
||||
expect(isAppUpdateEnabled).toBe(false);
|
||||
expect(testState.fetchMock).not.toHaveBeenCalled();
|
||||
expect(testState.androidDownloadAndInstallUpdateMock).not.toHaveBeenCalled();
|
||||
expect(testState.browserOpenMock).not.toHaveBeenCalled();
|
||||
expect(testState.openMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('selects the matching electron release asset for the current desktop platform', async () => {
|
||||
@@ -267,17 +270,12 @@ describe('app-update', () => {
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('selects the latest android apk release asset', async () => {
|
||||
it('resolves Android updates to the GitHub release page', async () => {
|
||||
testState.capacitorPlatform = 'android';
|
||||
testState.fetchMock.mockResolvedValueOnce(
|
||||
createFetchResponse({
|
||||
tag_name: 'v9.9.9',
|
||||
assets: [
|
||||
{
|
||||
name: '5chan-9.9.9.apk',
|
||||
browser_download_url: 'https://github.com/bitsocialnet/5chan/releases/download/v9.9.9/5chan-9.9.9.apk',
|
||||
},
|
||||
],
|
||||
assets: [],
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -287,23 +285,32 @@ describe('app-update', () => {
|
||||
expect(result).toEqual({
|
||||
runtime: 'android',
|
||||
targetVersion: '9.9.9',
|
||||
assetName: '5chan-9.9.9.apk',
|
||||
downloadUrl: 'https://github.com/bitsocialnet/5chan/releases/download/v9.9.9/5chan-9.9.9.apk',
|
||||
releaseUrl: 'https://github.com/bitsocialnet/5chan/releases/tag/v9.9.9',
|
||||
});
|
||||
});
|
||||
|
||||
it('accepts configured local test asset hosts for native e2e builds', async () => {
|
||||
vi.stubEnv('VITE_APP_UPDATE_ALLOWED_DOWNLOAD_HOSTS', '10.0.2.2');
|
||||
testState.capacitorPlatform = 'android';
|
||||
it('accepts configured local test asset hosts for desktop e2e builds', async () => {
|
||||
vi.stubEnv('VITE_APP_UPDATE_ALLOWED_DOWNLOAD_HOSTS', '127.0.0.1');
|
||||
window.electronApi = {
|
||||
isElectron: true,
|
||||
getPlatform: () => testState.electronGetPlatformMock(),
|
||||
downloadAndInstallUpdate: (options) => testState.electronDownloadAndInstallUpdateMock(options),
|
||||
copyToClipboard: vi.fn(),
|
||||
automateUploadMedia: vi.fn(),
|
||||
} as Window['electronApi'];
|
||||
testState.electronGetPlatformMock.mockResolvedValue({
|
||||
platform: 'linux',
|
||||
arch: 'x64',
|
||||
version: 'v20.0.0',
|
||||
});
|
||||
testState.fetchMock.mockResolvedValueOnce(
|
||||
createFetchResponse({
|
||||
tag_name: 'v9.9.9',
|
||||
html_url: 'http://10.0.2.2:4010/releases/v9.9.9',
|
||||
html_url: 'http://127.0.0.1:4010/releases/v9.9.9',
|
||||
assets: [
|
||||
{
|
||||
name: '5chan-9.9.9.apk',
|
||||
browser_download_url: 'http://10.0.2.2:4010/assets/5chan-9.9.9.apk',
|
||||
name: '5chan-9.9.9-x64.AppImage',
|
||||
browser_download_url: 'http://127.0.0.1:4010/assets/5chan-9.9.9-x64.AppImage',
|
||||
},
|
||||
],
|
||||
}),
|
||||
@@ -313,11 +320,11 @@ describe('app-update', () => {
|
||||
const result = await resolveAvailableAppUpdate();
|
||||
|
||||
expect(result).toEqual({
|
||||
runtime: 'android',
|
||||
runtime: 'electron',
|
||||
targetVersion: '9.9.9',
|
||||
assetName: '5chan-9.9.9.apk',
|
||||
downloadUrl: 'http://10.0.2.2:4010/assets/5chan-9.9.9.apk',
|
||||
releaseUrl: 'http://10.0.2.2:4010/releases/v9.9.9',
|
||||
assetName: '5chan-9.9.9-x64.AppImage',
|
||||
downloadUrl: 'http://127.0.0.1:4010/assets/5chan-9.9.9-x64.AppImage',
|
||||
releaseUrl: 'http://127.0.0.1:4010/releases/v9.9.9',
|
||||
});
|
||||
});
|
||||
|
||||
@@ -341,7 +348,7 @@ describe('app-update', () => {
|
||||
expect(reloadMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('routes native update installs through the matching platform bridge', async () => {
|
||||
it('routes desktop update installs through the electron platform bridge', async () => {
|
||||
window.electronApi = {
|
||||
isElectron: true,
|
||||
getPlatform: () => testState.electronGetPlatformMock(),
|
||||
@@ -358,21 +365,26 @@ describe('app-update', () => {
|
||||
downloadUrl: 'https://github.com/bitsocialnet/5chan/releases/download/v9.9.9/5chan-9.9.9-x64.Setup.exe',
|
||||
releaseUrl: 'https://github.com/bitsocialnet/5chan/releases/tag/v9.9.9',
|
||||
});
|
||||
await applyAvailableAppUpdate({
|
||||
runtime: 'android',
|
||||
targetVersion: '9.9.9',
|
||||
assetName: '5chan-9.9.9.apk',
|
||||
downloadUrl: 'https://github.com/bitsocialnet/5chan/releases/download/v9.9.9/5chan-9.9.9.apk',
|
||||
releaseUrl: 'https://github.com/bitsocialnet/5chan/releases/tag/v9.9.9',
|
||||
});
|
||||
|
||||
expect(testState.electronDownloadAndInstallUpdateMock).toHaveBeenCalledWith({
|
||||
url: 'https://github.com/bitsocialnet/5chan/releases/download/v9.9.9/5chan-9.9.9-x64.Setup.exe',
|
||||
fileName: '5chan-9.9.9-x64.Setup.exe',
|
||||
});
|
||||
expect(testState.androidDownloadAndInstallUpdateMock).toHaveBeenCalledWith({
|
||||
url: 'https://github.com/bitsocialnet/5chan/releases/download/v9.9.9/5chan-9.9.9.apk',
|
||||
fileName: '5chan-9.9.9.apk',
|
||||
expect(testState.browserOpenMock).not.toHaveBeenCalled();
|
||||
expect(testState.openMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('opens the GitHub release page when applying an Android update', async () => {
|
||||
const { applyAvailableAppUpdate } = await loadModule();
|
||||
await applyAvailableAppUpdate({
|
||||
runtime: 'android',
|
||||
targetVersion: '9.9.9',
|
||||
releaseUrl: 'https://github.com/bitsocialnet/5chan/releases/tag/v9.9.9',
|
||||
});
|
||||
|
||||
expect(testState.browserOpenMock).toHaveBeenCalledWith({
|
||||
url: 'https://github.com/bitsocialnet/5chan/releases/tag/v9.9.9',
|
||||
});
|
||||
expect(testState.openMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
+39
-10
@@ -24,14 +24,22 @@ interface WebAppUpdateInfo {
|
||||
releaseUrl: string;
|
||||
}
|
||||
|
||||
interface NativeAppUpdateInfo {
|
||||
runtime: 'electron' | 'android';
|
||||
interface ElectronAppUpdateInfo {
|
||||
runtime: 'electron';
|
||||
targetVersion: string;
|
||||
assetName: string;
|
||||
downloadUrl: string;
|
||||
releaseUrl: string;
|
||||
}
|
||||
|
||||
interface AndroidAppUpdateInfo {
|
||||
runtime: 'android';
|
||||
targetVersion: string;
|
||||
releaseUrl: string;
|
||||
}
|
||||
|
||||
type NativeAppUpdateInfo = ElectronAppUpdateInfo | AndroidAppUpdateInfo;
|
||||
|
||||
type AvailableAppUpdate = WebAppUpdateInfo | NativeAppUpdateInfo;
|
||||
|
||||
const getAppRuntime = (): AppRuntime => {
|
||||
@@ -160,10 +168,21 @@ const fetchLatestReleaseUpdate = async (runtime: Extract<AppRuntime, 'electron'
|
||||
return null;
|
||||
}
|
||||
|
||||
const releaseUrl =
|
||||
typeof releaseData.html_url === 'string' && releaseData.html_url.trim().length > 0 ? releaseData.html_url : getReleaseUrl(releaseData.tag_name || targetVersion);
|
||||
|
||||
if (runtime === 'android') {
|
||||
return {
|
||||
runtime,
|
||||
targetVersion,
|
||||
releaseUrl,
|
||||
};
|
||||
}
|
||||
|
||||
const assets = Array.isArray(releaseData.assets)
|
||||
? releaseData.assets.filter((asset) => typeof asset?.name === 'string' && typeof asset?.browser_download_url === 'string')
|
||||
: [];
|
||||
const matchedAsset = runtime === 'android' ? assets.find((asset) => asset.name.toLowerCase().endsWith('.apk')) || null : await findMatchingElectronAsset(assets);
|
||||
const matchedAsset = await findMatchingElectronAsset(assets);
|
||||
|
||||
if (!matchedAsset || !isAllowedDownloadUrl(matchedAsset.browser_download_url)) {
|
||||
return null;
|
||||
@@ -174,8 +193,7 @@ const fetchLatestReleaseUpdate = async (runtime: Extract<AppRuntime, 'electron'
|
||||
targetVersion,
|
||||
assetName: matchedAsset.name,
|
||||
downloadUrl: matchedAsset.browser_download_url,
|
||||
releaseUrl:
|
||||
typeof releaseData.html_url === 'string' && releaseData.html_url.trim().length > 0 ? releaseData.html_url : getReleaseUrl(releaseData.tag_name || targetVersion),
|
||||
releaseUrl,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -204,6 +222,18 @@ const resolveAvailableAppUpdate = async (): Promise<AvailableAppUpdate | null> =
|
||||
return fetchLatestReleaseUpdate(runtime);
|
||||
};
|
||||
|
||||
const openReleasePage = (url: string): void => {
|
||||
const openedWindow = window.open(url, '_blank', 'noopener,noreferrer');
|
||||
if (!openedWindow) {
|
||||
window.location.assign(url);
|
||||
}
|
||||
};
|
||||
|
||||
const openAndroidReleasePage = async (url: string): Promise<void> => {
|
||||
const { Browser } = await import('@capacitor/browser');
|
||||
await Browser.open({ url });
|
||||
};
|
||||
|
||||
const applyAvailableAppUpdate = async (update: AvailableAppUpdate): Promise<void> => {
|
||||
if (!isAppUpdateEnabled) {
|
||||
throw new Error('App updates are disabled for this build');
|
||||
@@ -229,12 +259,11 @@ const applyAvailableAppUpdate = async (update: AvailableAppUpdate): Promise<void
|
||||
return;
|
||||
}
|
||||
|
||||
const { default: AppUpdater } = await import('../plugins/app-updater');
|
||||
await AppUpdater.downloadAndInstallUpdate({
|
||||
url: update.downloadUrl,
|
||||
fileName: update.assetName,
|
||||
await openAndroidReleasePage(update.releaseUrl).catch((error) => {
|
||||
console.error('Failed to open Android release page with native browser', error);
|
||||
openReleasePage(update.releaseUrl);
|
||||
});
|
||||
};
|
||||
|
||||
export type { AppRuntime, AvailableAppUpdate, NativeAppUpdateInfo, WebAppUpdateInfo };
|
||||
export type { AndroidAppUpdateInfo, AppRuntime, AvailableAppUpdate, ElectronAppUpdateInfo, NativeAppUpdateInfo, WebAppUpdateInfo };
|
||||
export { applyAvailableAppUpdate, fetchLatestStableVersion, getAppRuntime, isAppUpdateEnabled, isElectron, refreshServiceWorkerRegistration, resolveAvailableAppUpdate };
|
||||
|
||||
@@ -1,14 +0,0 @@
|
||||
import { registerPlugin } from '@capacitor/core';
|
||||
|
||||
interface DownloadAndInstallUpdateOptions {
|
||||
url: string;
|
||||
fileName: string;
|
||||
}
|
||||
|
||||
interface AppUpdaterPlugin {
|
||||
downloadAndInstallUpdate(options: DownloadAndInstallUpdateOptions): Promise<void>;
|
||||
}
|
||||
|
||||
const AppUpdater = registerPlugin<AppUpdaterPlugin>('AppUpdater');
|
||||
|
||||
export default AppUpdater;
|
||||
@@ -12,6 +12,7 @@ __metadata:
|
||||
"@bitsocial/bitsocial-react-hooks": "npm:0.1.6"
|
||||
"@capacitor/android": "npm:7.4.5"
|
||||
"@capacitor/app": "npm:7.0.1"
|
||||
"@capacitor/browser": "npm:7.0.5"
|
||||
"@capacitor/cli": "npm:7.4.5"
|
||||
"@capacitor/core": "npm:7.4.5"
|
||||
"@capacitor/status-bar": "npm:7.0.1"
|
||||
@@ -1587,6 +1588,15 @@ __metadata:
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@capacitor/browser@npm:7.0.5":
|
||||
version: 7.0.5
|
||||
resolution: "@capacitor/browser@npm:7.0.5"
|
||||
peerDependencies:
|
||||
"@capacitor/core": ">=7.0.0"
|
||||
checksum: 10c0/d88cdfb2d0368e8b6edaf894989b1f9d957965d34fd8b5002b0fff05eee1b51d7a7cfa048624f13bd0e41530262655aa632f4cb8621f77ac789a642738081981
|
||||
languageName: node
|
||||
linkType: hard
|
||||
|
||||
"@capacitor/cli@npm:7.4.5":
|
||||
version: 7.4.5
|
||||
resolution: "@capacitor/cli@npm:7.4.5"
|
||||
|
||||
Reference in New Issue
Block a user