Skip to content
Open
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
// Copyright 2026 The Outline Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package org.outline.vpn;

import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNull;
import static org.junit.Assume.assumeTrue;

import android.content.ComponentName;
import android.content.Context;
import android.content.ContextWrapper;
import android.content.Intent;
import android.net.VpnService;

import androidx.test.core.app.ApplicationProvider;

import org.junit.Before;
import org.junit.Test;

/** Regression tests for OUTLINE-CLIENTS-NE2GP (ForegroundServiceDidNotStartInTimeException). */
public class VpnServiceStarterTest {

/** Records service start requests instead of starting the service. */
private static class RecordingContext extends ContextWrapper {
Intent startedService;

RecordingContext(Context base) {
super(base);
}

@Override
public ComponentName startForegroundService(Intent service) {
startedService = service;
return service.getComponent();
}

@Override
public ComponentName startService(Intent service) {
startedService = service;
return service.getComponent();
}
}

private RecordingContext context;
private VpnTunnelStore tunnelStore;

@Before
public void setUp() {
context = new RecordingContext(ApplicationProvider.getApplicationContext());
tunnelStore = new VpnTunnelStore(context);
}

@Test
public void bootWithoutVpnConsent_clearsStatusAndDoesNotStartService() {
// The test package has never been granted VPN consent on a clean device/emulator.
assumeTrue(VpnService.prepare(context) != null);
tunnelStore.setTunnelStatus(VpnTunnelService.TunnelStatus.CONNECTED);

new VpnServiceStarter().onReceive(context, new Intent(Intent.ACTION_BOOT_COMPLETED));

assertNull(context.startedService);
assertEquals(VpnTunnelService.TunnelStatus.DISCONNECTED, tunnelStore.getTunnelStatus());
}

@Test
public void packageReplacedWithoutVpnConsent_clearsStatusAndDoesNotStartService() {
assumeTrue(VpnService.prepare(context) != null);
tunnelStore.setTunnelStatus(VpnTunnelService.TunnelStatus.CONNECTED);

new VpnServiceStarter().onReceive(context, new Intent(Intent.ACTION_MY_PACKAGE_REPLACED));

assertNull(context.startedService);
assertEquals(VpnTunnelService.TunnelStatus.DISCONNECTED, tunnelStore.getTunnelStatus());
}

@Test
public void notConnectedAtShutdown_doesNotStartService() {
tunnelStore.setTunnelStatus(VpnTunnelService.TunnelStatus.DISCONNECTED);

new VpnServiceStarter().onReceive(context, new Intent(Intent.ACTION_BOOT_COMPLETED));

assertNull(context.startedService);
}

@Test
public void unrelatedAction_doesNotStartService() {
tunnelStore.setTunnelStatus(VpnTunnelService.TunnelStatus.CONNECTED);

new VpnServiceStarter().onReceive(context, new Intent(Intent.ACTION_SCREEN_ON));

assertNull(context.startedService);
assertEquals(VpnTunnelService.TunnelStatus.CONNECTED, tunnelStore.getTunnelStatus());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
// Copyright 2026 The Outline Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package org.outline.vpn;

import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertNull;

import android.app.Notification;
import android.content.Context;
import android.content.Intent;

import androidx.test.core.app.ApplicationProvider;

import org.junit.Before;
import org.junit.Test;

/**
* Regression tests for OUTLINE-CLIENTS-NE2GP: the foreground notification must build even when
* app resources are missing, otherwise startForeground() is never called.
*/
public class VpnTunnelServiceNotificationTest {
private Context context;

@Before
public void setUp() {
context = ApplicationProvider.getApplicationContext();
}

@Test
public void missingLauncherActivity_buildsNotificationWithoutTapAction() {
final Notification notification =
VpnTunnelService.buildNotification(context, "server", null, android.R.drawable.ic_dialog_info)
.build();

assertNull(notification.contentIntent);
assertEquals(android.R.drawable.ic_dialog_info, notification.getSmallIcon().getResId());
}

@Test
public void missingSmallIcon_buildsNotificationWithFallbackIcon() {
final Notification notification =
VpnTunnelService.buildNotification(context, "server", null, 0).build();

assertNotNull(notification.getSmallIcon());
assertEquals(AutoStartPolicy.FALLBACK_SMALL_ICON, notification.getSmallIcon().getResId());
}

@Test
public void launcherActivityPresent_setsTapAction() {
final Intent launchIntent = new Intent(Intent.ACTION_MAIN).setPackage(context.getPackageName());
final Notification notification =
VpnTunnelService.buildNotification(context, "server", launchIntent, 0).build();

assertNotNull(notification.contentIntent);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
// Copyright 2026 The Outline Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package org.outline.vpn;

/**
* Decision logic for the VPN auto-start path (boot, package update, always-on).
*
* <p>Kept free of Android framework calls so it can be covered by local unit tests. Once
* Context.startForegroundService() is called, Android kills the app unless the service calls
* Service.startForeground() in time, so these rules decide when the service may be started and
* when an auto-connect must be aborted.
*/
final class AutoStartPolicy {
/** Icon used for the foreground notification when the app's small_icon cannot be resolved. */
static final int FALLBACK_SMALL_ICON = android.R.drawable.ic_lock_lock;

/** Outcome of an auto-connect attempt inside the service. */
enum Decision {
START_TUNNEL,
ABORT,
}

private AutoStartPolicy() {}

/**
* Whether VpnServiceStarter should start the service in the foreground.
*
* @param wasConnected whether the stored tunnel status was CONNECTED.
* @param vpnConsentGranted whether VpnService.prepare() returned null.
*/
static boolean shouldStartService(boolean wasConnected, boolean vpnConsentGranted) {
return wasConnected && vpnConsentGranted;
}

/**
* Whether the service should go on to start the tunnel or abort the auto-connect.
*
* @param startedInForeground whether Service.startForeground() succeeded.
* @param hasStoredTunnel whether a last successful tunnel was found.
* @param vpnConsentGranted whether VpnService.prepare() returned null.
*/
static Decision decideAutoConnect(
boolean startedInForeground, boolean hasStoredTunnel, boolean vpnConsentGranted) {
if (!startedInForeground || !hasStoredTunnel || !vpnConsentGranted) {
return Decision.ABORT;
}
return Decision.START_TUNNEL;
}

/** Returns the resolved small icon resource ID, or a fallback if it couldn't be resolved. */
static int smallIconOrFallback(int resolvedIconId) {
return resolvedIconId != 0 ? resolvedIconId : FALLBACK_SMALL_ICON;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
import android.content.BroadcastReceiver;
import android.content.Context;
import android.content.Intent;
import android.net.VpnService;
import android.os.Build;

// Starts the VpnTunnelService on boot and after app updates. Receives broadcasts for
Expand All @@ -43,6 +44,15 @@ public void onReceive(Context context, Intent intent) {
if (!wasConnectedAtShutdown) {
return;
}
// The VPN cannot be prepared from the background (it requires UI). If consent is missing (e.g.
// revoked, or granted to another VPN app), don't start the service: once
// startForegroundService() is called, Android requires the service to call startForeground()
// in time, and the service would immediately abort anyway. Clear the stale status instead.
final boolean vpnConsentGranted = VpnService.prepare(context) == null;
if (!AutoStartPolicy.shouldStartService(wasConnectedAtShutdown, vpnConsentGranted)) {
tunnelStore.setTunnelStatus(VpnTunnelService.TunnelStatus.DISCONNECTED);
return;
}
Intent serviceIntent = new Intent(context, VpnTunnelService.class);
serviceIntent.putExtra(AUTOSTART_EXTRA, true);
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
Expand Down
Loading
Loading