Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 3 additions & 4 deletions android/app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
12 changes: 12 additions & 0 deletions android/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,12 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SYSTEM_EXEMPTED" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />

<!-- Guards the ACTION_EXITED receiver of MainActivity before Android 13 -->
<permission
android:name="org.openipc.camex.permission.INTERNAL"
android:protectionLevel="signature" />
<uses-permission android:name="org.openipc.camex.permission.INTERNAL" />

<application
android:label="Camex VPN"
android:icon="@mipmap/ic_launcher"
Expand All @@ -31,6 +37,12 @@
</intent-filter>
</activity>

<activity
android:name=".SettingsActivity"
android:exported="false"
android:label="@string/settings_title"
android:theme="@style/Theme.Camex" />

<service
android:name=".TunnelService"
android:exported="false"
Expand Down
26 changes: 15 additions & 11 deletions android/app/src/main/java/org/openipc/camex/MainActivity.java
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
package org.openipc.camex;

import android.Manifest;
import android.app.Activity;
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
Expand All @@ -18,10 +19,8 @@
import android.widget.Button;
import android.widget.TextView;
import android.widget.Toast;
import androidx.appcompat.app.AppCompatActivity;
import androidx.core.content.ContextCompat;

public class MainActivity extends AppCompatActivity {
public class MainActivity extends Activity {
private TextView statusView;
private Button startButton, stopButton, settingsButton;
private boolean running = false;
Expand Down Expand Up @@ -51,14 +50,19 @@ protected void onCreate(Bundle savedInstanceState) {

startButton.setOnClickListener(v -> 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);
Comment on lines +60 to +61

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

1. Settings crash on older android 📎 Requirement gap ☼ Reliability

MainActivity.onCreate() unconditionally calls the five-argument registerReceiver overload, which
is available only on API 33 and newer. On supported Android 8–12 devices (API 26–32), launching the
activity reaches that unavailable method before the main screen, settings, or VPN controls are
usable.
Agent Prompt
## Issue description
`MainActivity` uses an API-33-only receiver-registration overload during launcher activity startup, so supported Android 8–12 devices cannot open the main screen.

## Fix Focus Areas
- android/app/src/main/java/org/openipc/camex/MainActivity.java[57-61]
- android/app/build.gradle.kts[12-14]

## Recommended Fix
Guard receiver registration with an API 33 check. On API 33 and newer, use the five-argument `registerReceiver` overload with `RECEIVER_NOT_EXPORTED`; on older supported versions, use the four-argument overload with `TunnelService.PERMISSION_INTERNAL` so the receiver remains protected by the signature permission.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

}

private void openSettings() {
startActivity(new Intent(this, SettingsActivity.class));
}

@Override
Expand All @@ -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);
}
Expand All @@ -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);
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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));
Expand All @@ -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) {
Expand Down Expand Up @@ -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)
Expand All @@ -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 {
Expand Down
15 changes: 11 additions & 4 deletions android/app/src/main/java/org/openipc/camex/TunnelService.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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";
Expand Down Expand Up @@ -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) {
Expand Down
2 changes: 1 addition & 1 deletion android/app/src/main/res/layout/activity_main.xml
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@

<Button
android:id="@+id/btn_settings"
style="?attr/borderlessButtonStyle"
style="?android:attr/borderlessButtonStyle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/btn_settings"
Expand Down
Loading
Loading