From dd9f9d2e8a9f4ceec08e9ef2a32b3efc73b30481 Mon Sep 17 00:00:00 2001 From: Compot Developer Date: Sat, 17 Oct 2026 15:53:32 +0000 Subject: [PATCH 1/2] android: drop AppCompat and Material (#55, step B) The app used AppCompat for its activity and theme and Material only for the settings bottom sheet; together they were most of classes.dex and resources.arsc. - MainActivity extends Activity; the theme is the platform Material one, light with a dark action bar, dark in night mode (as DayNight). - Settings are a SettingsActivity with EditText, RadioGroup, Switch and a "Show the key" check box instead of the bottom sheet; same fields, preference keys and validation. Typed values survive a rotation. - TunnelService calls startForeground() itself: with the type on Android 14+, where systemExempted exists, without it before. - The ACTION_EXITED receiver is registered with the not-exported flag and, for Android 8-12, a signature permission of the app, as ContextCompat.registerReceiver did. - Only androidx.annotation is left (@Keep for the native callback). Release APK built locally: 1967120 -> 513198 bytes (v3.0.1: 2509592). --- android/app/build.gradle.kts | 7 +- android/app/src/main/AndroidManifest.xml | 12 ++ .../java/org/openipc/camex/MainActivity.java | 26 ++- ...BottomSheet.java => SettingsActivity.java} | 97 +++++---- .../java/org/openipc/camex/TunnelService.java | 15 +- .../app/src/main/res/layout/activity_main.xml | 2 +- .../src/main/res/layout/activity_settings.xml | 204 ++++++++++++++++++ .../main/res/layout/bottom_sheet_settings.xml | 197 ----------------- .../app/src/main/res/values-night/themes.xml | 9 + android/app/src/main/res/values/strings.xml | 3 +- android/app/src/main/res/values/themes.xml | 7 +- 11 files changed, 311 insertions(+), 268 deletions(-) rename android/app/src/main/java/org/openipc/camex/{SettingsBottomSheet.java => SettingsActivity.java} (66%) create mode 100644 android/app/src/main/res/layout/activity_settings.xml delete mode 100644 android/app/src/main/res/layout/bottom_sheet_settings.xml create mode 100644 android/app/src/main/res/values-night/themes.xml diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index b2722eb..f672308 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -58,8 +58,7 @@ android { } dependencies { - implementation("androidx.appcompat:appcompat:1.6.1") - // core 1.12.0+ provides ServiceCompat.startForeground(..., foregroundServiceType) - implementation("androidx.core:core:1.12.0") - implementation("com.google.android.material:material:1.11.0") + // Platform classes only (#55): no AppCompat, Material or AndroidX core. + // @Keep for the method native code calls; R8's default rules honour it. + implementation("androidx.annotation:annotation:1.7.1") } diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 042eaed..d52c595 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -15,6 +15,12 @@ + + + + + + startVpn()); stopButton.setOnClickListener(v -> stopVpn()); - settingsButton.setOnClickListener(v -> - new SettingsBottomSheet().show(getSupportFragmentManager(), "settings")); + settingsButton.setOnClickListener(v -> openSettings()); requestNotificationPermission(); updateStatus(false); - ContextCompat.registerReceiver(this, exitReceiver, - new IntentFilter(TunnelService.ACTION_EXITED), - ContextCompat.RECEIVER_NOT_EXPORTED); + // Only our own service may send ACTION_EXITED: Android 13+ honours the + // not-exported flag, older versions the signature permission of this app + // (what ContextCompat.registerReceiver does) + registerReceiver(exitReceiver, new IntentFilter(TunnelService.ACTION_EXITED), + TunnelService.PERMISSION_INTERNAL, null, RECEIVER_NOT_EXPORTED); + } + + private void openSettings() { + startActivity(new Intent(this, SettingsActivity.class)); } @Override @@ -70,7 +74,7 @@ protected void onDestroy() { // Android 13+ needs runtime POST_NOTIFICATIONS for the foreground notification. private void requestNotificationPermission() { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU - && ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) + && checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) { requestPermissions(new String[]{ Manifest.permission.POST_NOTIFICATIONS }, 1); } @@ -79,10 +83,10 @@ private void requestNotificationPermission() { private void startVpn() { // Protocol v2 needs both keys: without them camex cannot start SharedPreferences prefs = getSharedPreferences("camex", MODE_PRIVATE); - if (!SettingsBottomSheet.isServerPubkey(prefs.getString("server_pubkey", "")) + if (!SettingsActivity.isServerPubkey(prefs.getString("server_pubkey", "")) || prefs.getString("psk", "").isEmpty()) { Toast.makeText(this, R.string.err_keys_missing, Toast.LENGTH_LONG).show(); - new SettingsBottomSheet().show(getSupportFragmentManager(), "settings"); + openSettings(); return; } Intent intent = VpnService.prepare(this); diff --git a/android/app/src/main/java/org/openipc/camex/SettingsBottomSheet.java b/android/app/src/main/java/org/openipc/camex/SettingsActivity.java similarity index 66% rename from android/app/src/main/java/org/openipc/camex/SettingsBottomSheet.java rename to android/app/src/main/java/org/openipc/camex/SettingsActivity.java index f80868d..8c450f6 100644 --- a/android/app/src/main/java/org/openipc/camex/SettingsBottomSheet.java +++ b/android/app/src/main/java/org/openipc/camex/SettingsActivity.java @@ -1,31 +1,28 @@ /* * Copyright (c) OpenIPC https://openipc.org MIT License * - * SettingsBottomSheet.java — camex connection settings (Material bottom sheet) + * SettingsActivity.java — camex connection settings (platform widgets only) */ package org.openipc.camex; +import android.app.Activity; import android.content.SharedPreferences; import android.os.Bundle; +import android.text.InputType; import android.text.TextUtils; import android.util.Base64; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; +import android.widget.Button; +import android.widget.CheckBox; +import android.widget.EditText; +import android.widget.RadioGroup; +import android.widget.Switch; import android.widget.Toast; -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import com.google.android.material.bottomsheet.BottomSheetDialogFragment; -import com.google.android.material.button.MaterialButton; -import com.google.android.material.button.MaterialButtonToggleGroup; -import com.google.android.material.switchmaterial.SwitchMaterial; -import com.google.android.material.textfield.TextInputEditText; /** * Editable connection settings persisted to the "camex" SharedPreferences, read * back by {@link TunnelService} when building the camex argv. */ -public class SettingsBottomSheet extends BottomSheetDialogFragment { +public class SettingsActivity extends Activity { private static final String PREFS = "camex"; private static final int DEFAULT_PORT = 5800; @@ -35,36 +32,47 @@ public class SettingsBottomSheet extends BottomSheetDialogFragment { private static final int DEFAULT_KEEPALIVE = 10; private static final int DEFAULT_TIMEOUT = 20; - private TextInputEditText hostInput, portInput, nameInput, pskInput; - private TextInputEditText pubkeyInput; - private TextInputEditText mtuInput, keepaliveInput, timeoutInput; - private SwitchMaterial fullTunnelSwitch; - private MaterialButtonToggleGroup transportGroup; + private EditText hostInput, portInput, nameInput, pskInput; + private EditText pubkeyInput; + private EditText mtuInput, keepaliveInput, timeoutInput; + private Switch fullTunnelSwitch; + private RadioGroup transportGroup; - @Nullable @Override - public View onCreateView(@NonNull LayoutInflater inflater, - @Nullable ViewGroup container, - @Nullable Bundle savedInstanceState) { - return inflater.inflate(R.layout.bottom_sheet_settings, container, false); + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setTitle(R.string.settings_title); + setContentView(R.layout.activity_settings); + + hostInput = findViewById(R.id.input_server_host); + portInput = findViewById(R.id.input_server_port); + nameInput = findViewById(R.id.input_name); + pskInput = findViewById(R.id.input_psk); + pubkeyInput = findViewById(R.id.input_server_pubkey); + fullTunnelSwitch = findViewById(R.id.switch_full_tunnel); + transportGroup = findViewById(R.id.group_transport); + mtuInput = findViewById(R.id.input_mtu); + keepaliveInput = findViewById(R.id.input_keepalive); + timeoutInput = findViewById(R.id.input_server_timeout); + + CheckBox showPsk = findViewById(R.id.check_show_psk); + showPsk.setOnCheckedChangeListener((b, show) -> { + int sel = pskInput.getSelectionEnd(); + pskInput.setInputType(InputType.TYPE_CLASS_TEXT | (show + ? InputType.TYPE_TEXT_VARIATION_VISIBLE_PASSWORD + : InputType.TYPE_TEXT_VARIATION_PASSWORD)); + pskInput.setSelection(Math.max(sel, 0)); + }); + + SharedPreferences prefs = getSharedPreferences(PREFS, MODE_PRIVATE); + // After a rotation the views restore what was typed: keep it + if (savedInstanceState == null) load(prefs); + + Button save = findViewById(R.id.btn_save); + save.setOnClickListener(view -> save(prefs)); } - @Override - public void onViewCreated(@NonNull View v, @Nullable Bundle savedInstanceState) { - super.onViewCreated(v, savedInstanceState); - - hostInput = v.findViewById(R.id.input_server_host); - portInput = v.findViewById(R.id.input_server_port); - nameInput = v.findViewById(R.id.input_name); - pskInput = v.findViewById(R.id.input_psk); - pubkeyInput = v.findViewById(R.id.input_server_pubkey); - fullTunnelSwitch = v.findViewById(R.id.switch_full_tunnel); - transportGroup = v.findViewById(R.id.group_transport); - mtuInput = v.findViewById(R.id.input_mtu); - keepaliveInput = v.findViewById(R.id.input_keepalive); - timeoutInput = v.findViewById(R.id.input_server_timeout); - - SharedPreferences prefs = requireContext().getSharedPreferences(PREFS, 0); + private void load(SharedPreferences prefs) { hostInput.setText(prefs.getString("server_host", "")); portInput.setText(String.valueOf(prefs.getInt("server_port", DEFAULT_PORT))); nameInput.setText(prefs.getString("name", DEFAULT_NAME)); @@ -79,9 +87,6 @@ public void onViewCreated(@NonNull View v, @Nullable Bundle savedInstanceState) mtuInput.setText(String.valueOf(prefs.getInt("mtu", DEFAULT_MTU))); keepaliveInput.setText(String.valueOf(prefs.getInt("keepalive", DEFAULT_KEEPALIVE))); timeoutInput.setText(String.valueOf(prefs.getInt("server_timeout", DEFAULT_TIMEOUT))); - - MaterialButton save = v.findViewById(R.id.btn_save); - save.setOnClickListener(view -> save(prefs)); } private void save(SharedPreferences prefs) { @@ -137,7 +142,7 @@ private void save(SharedPreferences prefs) { } String transport = - transportGroup.getCheckedButtonId() == R.id.btn_tcp ? "tcp" : "udp"; + transportGroup.getCheckedRadioButtonId() == R.id.btn_tcp ? "tcp" : "udp"; prefs.edit() .putString("server_host", host) @@ -152,15 +157,15 @@ private void save(SharedPreferences prefs) { .putInt("server_timeout", timeout) .apply(); - Toast.makeText(requireContext(), R.string.save, Toast.LENGTH_SHORT).show(); - dismiss(); + Toast.makeText(this, R.string.save, Toast.LENGTH_SHORT).show(); + finish(); } - private static String text(TextInputEditText input) { + private static String text(EditText input) { return input.getText() == null ? "" : input.getText().toString().trim(); } - private static int parseInt(TextInputEditText input, int fallback) { + private static int parseInt(EditText input, int fallback) { String s = text(input); if (TextUtils.isEmpty(s)) return fallback; try { diff --git a/android/app/src/main/java/org/openipc/camex/TunnelService.java b/android/app/src/main/java/org/openipc/camex/TunnelService.java index a8882fe..47de68b 100644 --- a/android/app/src/main/java/org/openipc/camex/TunnelService.java +++ b/android/app/src/main/java/org/openipc/camex/TunnelService.java @@ -17,7 +17,6 @@ import android.os.SystemClock; import android.util.Log; import androidx.annotation.Keep; -import androidx.core.app.ServiceCompat; import java.util.ArrayList; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; @@ -27,6 +26,8 @@ public class TunnelService extends VpnService { public static final String ACTION_STOP = "org.openipc.camex.STOP"; /** Sent (to this app only) when camex has exited: the UI shows it. */ public static final String ACTION_EXITED = "org.openipc.camex.EXITED"; + // Signature permission (AndroidManifest.xml) of the ACTION_EXITED receiver + public static final String PERMISSION_INTERNAL = "org.openipc.camex.permission.INTERNAL"; private static final String CHANNEL_ID = "camex_vpn"; private static final int NOTIFY_ID = 1; private static final String TAG = "camex"; @@ -77,9 +78,15 @@ public int onStartCommand(Intent intent, int flags, int startId) { } // Android 14 (targetSdk 34) requires a foreground service type; VPN apps - // use systemExempted. ServiceCompat picks the right overload per API. - ServiceCompat.startForeground(this, NOTIFY_ID, buildNotification("Starting..."), - ServiceInfo.FOREGROUND_SERVICE_TYPE_SYSTEM_EXEMPTED); + // use systemExempted, a type that exists only from Android 14; before it + // the two-argument form takes the type from the manifest. + Notification starting = buildNotification("Starting..."); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + startForeground(NOTIFY_ID, starting, + ServiceInfo.FOREGROUND_SERVICE_TYPE_SYSTEM_EXEMPTED); + } else { + startForeground(NOTIFY_ID, starting); + } final long generation; synchronized (lock) { diff --git a/android/app/src/main/res/layout/activity_main.xml b/android/app/src/main/res/layout/activity_main.xml index 304b205..cec59b2 100644 --- a/android/app/src/main/res/layout/activity_main.xml +++ b/android/app/src/main/res/layout/activity_main.xml @@ -34,7 +34,7 @@