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:
Tommaso Casaburi
2026-05-06 17:56:00 +07:00
committed by GitHub
parent 7c028c1f36
commit aaa0d88d6a
14 changed files with 108 additions and 586 deletions
+1
View File
@@ -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));
}
}
+3
View File
@@ -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')