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 @@