]> git.ipfire.org Git - thirdparty/strongswan.git/blob - src/frontends/android/app/src/main/java/org/strongswan/android/ui/VpnProfileControlActivity.java
android: Ask user to add our app to the device's power whitelist
[thirdparty/strongswan.git] / src / frontends / android / app / src / main / java / org / strongswan / android / ui / VpnProfileControlActivity.java
1 /*
2 * Copyright (C) 2012-2020 Tobias Brunner
3 * HSR Hochschule fuer Technik Rapperswil
4 *
5 * This program is free software; you can redistribute it and/or modify it
6 * under the terms of the GNU General Public License as published by the
7 * Free Software Foundation; either version 2 of the License, or (at your
8 * option) any later version. See <http://www.fsf.org/copyleft/gpl.txt>.
9 *
10 * This program is distributed in the hope that it will be useful, but
11 * WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY
12 * or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License
13 * for more details.
14 */
15
16 package org.strongswan.android.ui;
17
18 import android.app.Dialog;
19 import android.app.Service;
20 import android.content.ActivityNotFoundException;
21 import android.content.ComponentName;
22 import android.content.Context;
23 import android.content.DialogInterface;
24 import android.content.Intent;
25 import android.content.ServiceConnection;
26 import android.net.Uri;
27 import android.net.VpnService;
28 import android.os.Build;
29 import android.os.Bundle;
30 import android.os.IBinder;
31 import android.os.PowerManager;
32 import android.provider.Settings;
33 import android.view.LayoutInflater;
34 import android.view.View;
35 import android.widget.EditText;
36 import android.widget.Toast;
37
38 import org.strongswan.android.R;
39 import org.strongswan.android.data.VpnProfile;
40 import org.strongswan.android.data.VpnProfileDataSource;
41 import org.strongswan.android.data.VpnType.VpnTypeFeature;
42 import org.strongswan.android.logic.VpnStateService;
43 import org.strongswan.android.logic.VpnStateService.State;
44
45 import androidx.annotation.NonNull;
46 import androidx.appcompat.app.AlertDialog;
47 import androidx.appcompat.app.AppCompatActivity;
48 import androidx.appcompat.app.AppCompatDialogFragment;
49 import androidx.fragment.app.Fragment;
50 import androidx.fragment.app.FragmentManager;
51 import androidx.fragment.app.FragmentTransaction;
52
53 public class VpnProfileControlActivity extends AppCompatActivity
54 {
55 public static final String START_PROFILE = "org.strongswan.android.action.START_PROFILE";
56 public static final String DISCONNECT = "org.strongswan.android.action.DISCONNECT";
57 public static final String EXTRA_VPN_PROFILE_ID = "org.strongswan.android.VPN_PROFILE_ID";
58
59 private static final int PREPARE_VPN_SERVICE = 0;
60 private static final int ADD_TO_POWER_WHITELIST = 1;
61 private static final String WAITING_FOR_RESULT = "WAITING_FOR_RESULT";
62 private static final String PROFILE_NAME = "PROFILE_NAME";
63 private static final String PROFILE_REQUIRES_PASSWORD = "REQUIRES_PASSWORD";
64 private static final String PROFILE_RECONNECT = "RECONNECT";
65 private static final String PROFILE_DISCONNECT = "DISCONNECT";
66 private static final String DIALOG_TAG = "Dialog";
67
68 private Bundle mProfileInfo;
69 private boolean mWaitingForResult;
70 private VpnStateService mService;
71 private final ServiceConnection mServiceConnection = new ServiceConnection()
72 {
73 @Override
74 public void onServiceDisconnected(ComponentName name)
75 {
76 mService = null;
77 }
78
79 @Override
80 public void onServiceConnected(ComponentName name, IBinder service)
81 {
82 mService = ((VpnStateService.LocalBinder)service).getService();
83 handleIntent();
84 }
85 };
86
87 @Override
88 public void onCreate(Bundle savedInstanceState)
89 {
90 super.onCreate(savedInstanceState);
91
92 if (savedInstanceState != null)
93 {
94 mWaitingForResult = savedInstanceState.getBoolean(WAITING_FOR_RESULT, false);
95 }
96 this.bindService(new Intent(this, VpnStateService.class),
97 mServiceConnection, Service.BIND_AUTO_CREATE);
98 }
99
100 @Override
101 protected void onSaveInstanceState(Bundle outState)
102 {
103 super.onSaveInstanceState(outState);
104 outState.putBoolean(WAITING_FOR_RESULT, mWaitingForResult);
105 }
106
107 @Override
108 protected void onDestroy()
109 {
110 super.onDestroy();
111 if (mService != null)
112 {
113 this.unbindService(mServiceConnection);
114 }
115 }
116
117 /**
118 * Due to launchMode=singleTop this is called if the Activity already exists
119 */
120 @Override
121 protected void onNewIntent(Intent intent)
122 {
123 super.onNewIntent(intent);
124
125 /* store this intent in case the service is not yet connected or the activity is restarted */
126 setIntent(intent);
127
128 if (mService != null)
129 {
130 handleIntent();
131 }
132 }
133
134 /**
135 * Prepare the VpnService. If this succeeds the current VPN profile is
136 * started.
137 *
138 * @param profileInfo a bundle containing the information about the profile to be started
139 */
140 protected void prepareVpnService(Bundle profileInfo)
141 {
142 Intent intent;
143
144 if (mWaitingForResult)
145 {
146 mProfileInfo = profileInfo;
147 return;
148 }
149
150 try
151 {
152 intent = VpnService.prepare(this);
153 }
154 catch (IllegalStateException ex)
155 {
156 /* this happens if the always-on VPN feature (Android 4.2+) is activated */
157 VpnNotSupportedError.showWithMessage(this, R.string.vpn_not_supported_during_lockdown);
158 return;
159 }
160 catch (NullPointerException ex)
161 {
162 /* not sure when this happens exactly, but apparently it does */
163 VpnNotSupportedError.showWithMessage(this, R.string.vpn_not_supported);
164 return;
165 }
166 /* store profile info until the user grants us permission */
167 mProfileInfo = profileInfo;
168 if (intent != null)
169 {
170 try
171 {
172 mWaitingForResult = true;
173 startActivityForResult(intent, PREPARE_VPN_SERVICE);
174 }
175 catch (ActivityNotFoundException ex)
176 {
177 /* it seems some devices, even though they come with Android 4,
178 * don't have the VPN components built into the system image.
179 * com.android.vpndialogs/com.android.vpndialogs.ConfirmDialog
180 * will not be found then */
181 VpnNotSupportedError.showWithMessage(this, R.string.vpn_not_supported);
182 mWaitingForResult = false;
183 }
184 }
185 else
186 { /* user already granted permission to use VpnService */
187 onActivityResult(PREPARE_VPN_SERVICE, RESULT_OK, null);
188 }
189 }
190
191 /**
192 * Check if we are on the system's power whitelist, if necessary, or ask the user
193 * to add us.
194 * @return true if profile can be initiated immediately
195 */
196 private boolean checkPowerWhitelist()
197 {
198 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M)
199 {
200 PowerManager pm = (PowerManager)this.getSystemService(Context.POWER_SERVICE);
201 if (!pm.isIgnoringBatteryOptimizations(this.getPackageName()))
202 {
203 PowerWhitelistRequired whitelist = new PowerWhitelistRequired();
204 mWaitingForResult = true;
205 whitelist.show(getSupportFragmentManager(), DIALOG_TAG);
206 return false;
207 }
208 }
209 return true;
210 }
211
212 @Override
213 protected void onActivityResult(int requestCode, int resultCode, Intent data)
214 {
215 switch (requestCode)
216 {
217 case PREPARE_VPN_SERVICE:
218 mWaitingForResult = false;
219 if (resultCode == RESULT_OK && mProfileInfo != null)
220 {
221 if (checkPowerWhitelist())
222 {
223 if (mService != null)
224 {
225 mService.connect(mProfileInfo, true);
226 }
227 finish();
228 }
229 }
230 else
231 { /* this happens if the always-on VPN feature is activated by a different app or the user declined */
232 if (getSupportFragmentManager().isStateSaved())
233 { /* onActivityResult() might be called when we aren't active anymore e.g. if the
234 * user pressed the home button, if the activity is started again we land here
235 * before onNewIntent() is called */
236 return;
237 }
238 VpnNotSupportedError.showWithMessage(this, R.string.vpn_not_supported_no_permission);
239 }
240 break;
241 case ADD_TO_POWER_WHITELIST:
242 mWaitingForResult = false;
243 if (mProfileInfo != null && mService != null)
244 {
245 mService.connect(mProfileInfo, true);
246 }
247 finish();
248 break;
249 default:
250 super.onActivityResult(requestCode, resultCode, data);
251 }
252 }
253
254 /**
255 * Check if we are currently connected to a VPN connection
256 *
257 * @return true if currently connected
258 */
259 private boolean isConnected()
260 {
261 if (mService == null)
262 {
263 return false;
264 }
265 if (mService.getErrorState() != VpnStateService.ErrorState.NO_ERROR)
266 { /* allow reconnecting (even to a different profile) without confirmation if there is an error */
267 return false;
268 }
269 return (mService.getState() == State.CONNECTED || mService.getState() == State.CONNECTING);
270 }
271
272 /**
273 * Start the given VPN profile
274 *
275 * @param profile VPN profile
276 */
277 public void startVpnProfile(VpnProfile profile)
278 {
279 Bundle profileInfo = new Bundle();
280 profileInfo.putString(VpnProfileDataSource.KEY_UUID, profile.getUUID().toString());
281 profileInfo.putString(VpnProfileDataSource.KEY_USERNAME, profile.getUsername());
282 profileInfo.putString(VpnProfileDataSource.KEY_PASSWORD, profile.getPassword());
283 profileInfo.putBoolean(PROFILE_REQUIRES_PASSWORD, profile.getVpnType().has(VpnTypeFeature.USER_PASS));
284 profileInfo.putString(PROFILE_NAME, profile.getName());
285
286 removeFragmentByTag(DIALOG_TAG);
287
288 if (isConnected())
289 {
290 profileInfo.putBoolean(PROFILE_RECONNECT, mService.getProfile().getUUID().equals(profile.getUUID()));
291
292 ConfirmationDialog dialog = new ConfirmationDialog();
293 dialog.setArguments(profileInfo);
294 dialog.show(this.getSupportFragmentManager(), DIALOG_TAG);
295 return;
296 }
297 startVpnProfile(profileInfo);
298 }
299
300 /**
301 * Start the given VPN profile asking the user for a password if required.
302 *
303 * @param profileInfo data about the profile
304 */
305 private void startVpnProfile(Bundle profileInfo)
306 {
307 if (profileInfo.getBoolean(PROFILE_REQUIRES_PASSWORD) &&
308 profileInfo.getString(VpnProfileDataSource.KEY_PASSWORD) == null)
309 {
310 LoginDialog login = new LoginDialog();
311 login.setArguments(profileInfo);
312 login.show(getSupportFragmentManager(), DIALOG_TAG);
313 return;
314 }
315 prepareVpnService(profileInfo);
316 }
317
318 /**
319 * Start the VPN profile referred to by the given intent. Displays an error
320 * if the profile doesn't exist.
321 *
322 * @param intent Intent that caused us to start this
323 */
324 private void startVpnProfile(Intent intent)
325 {
326 VpnProfile profile = null;
327
328 VpnProfileDataSource dataSource = new VpnProfileDataSource(this);
329 dataSource.open();
330 String profileUUID = intent.getStringExtra(EXTRA_VPN_PROFILE_ID);
331 if (profileUUID != null)
332 {
333 profile = dataSource.getVpnProfile(profileUUID);
334 }
335 else
336 {
337 long profileId = intent.getLongExtra(EXTRA_VPN_PROFILE_ID, 0);
338 if (profileId > 0)
339 {
340 profile = dataSource.getVpnProfile(profileId);
341 }
342 }
343 dataSource.close();
344
345 if (profile != null)
346 {
347 startVpnProfile(profile);
348 }
349 else
350 {
351 Toast.makeText(this, R.string.profile_not_found, Toast.LENGTH_LONG).show();
352 finish();
353 }
354 }
355
356 /**
357 * Disconnect the current connection, if any (silently ignored if there is no connection).
358 *
359 * @param intent Intent that caused us to start this
360 */
361 private void disconnect(Intent intent)
362 {
363 VpnProfile profile = null;
364
365 removeFragmentByTag(DIALOG_TAG);
366
367 String profileUUID = intent.getStringExtra(EXTRA_VPN_PROFILE_ID);
368 if (profileUUID != null)
369 {
370 VpnProfileDataSource dataSource = new VpnProfileDataSource(this);
371 dataSource.open();
372 profile = dataSource.getVpnProfile(profileUUID);
373 dataSource.close();
374 }
375
376 if (mService != null)
377 {
378 if (mService.getState() == State.CONNECTED ||
379 mService.getState() == State.CONNECTING)
380 {
381 if (profile != null && profile.equals(mService.getProfile()))
382 { /* allow explicit termination without confirmation */
383 mService.disconnect();
384 finish();
385 return;
386 }
387 Bundle args = new Bundle();
388 args.putBoolean(PROFILE_DISCONNECT, true);
389
390 ConfirmationDialog dialog = new ConfirmationDialog();
391 dialog.setArguments(args);
392 dialog.show(this.getSupportFragmentManager(), DIALOG_TAG);
393 }
394 else
395 {
396 finish();
397 }
398 }
399 }
400
401 /**
402 * Handle the Intent of this Activity depending on its action
403 */
404 private void handleIntent()
405 {
406 Intent intent = getIntent();
407
408 if (START_PROFILE.equals(intent.getAction()))
409 {
410 startVpnProfile(intent);
411 }
412 else if (DISCONNECT.equals(intent.getAction()))
413 {
414 disconnect(intent);
415 }
416 }
417
418 /**
419 * Dismiss dialog if shown
420 */
421 public void removeFragmentByTag(String tag)
422 {
423 FragmentManager fm = getSupportFragmentManager();
424 Fragment login = fm.findFragmentByTag(tag);
425 if (login != null)
426 {
427 FragmentTransaction ft = fm.beginTransaction();
428 ft.remove(login);
429 ft.commit();
430 }
431 }
432
433 /**
434 * Class that displays a confirmation dialog if a VPN profile is already connected
435 * and then initiates the selected VPN profile if the user confirms the dialog.
436 */
437 public static class ConfirmationDialog extends AppCompatDialogFragment
438 {
439 @Override
440 public Dialog onCreateDialog(Bundle savedInstanceState)
441 {
442 final Bundle profileInfo = getArguments();
443 int icon = android.R.drawable.ic_dialog_alert;
444 int title = R.string.connect_profile_question;
445 int message = R.string.replaces_active_connection;
446 int button = R.string.connect;
447
448 if (profileInfo.getBoolean(PROFILE_RECONNECT))
449 {
450 icon = android.R.drawable.ic_dialog_info;
451 title = R.string.vpn_connected;
452 message = R.string.vpn_profile_connected;
453 button = R.string.reconnect;
454 }
455 else if (profileInfo.getBoolean(PROFILE_DISCONNECT))
456 {
457 title = R.string.disconnect_question;
458 message = R.string.disconnect_active_connection;
459 button = R.string.disconnect;
460 }
461
462 DialogInterface.OnClickListener connectListener = new DialogInterface.OnClickListener()
463 {
464 @Override
465 public void onClick(DialogInterface dialog, int which)
466 {
467 VpnProfileControlActivity activity = (VpnProfileControlActivity)getActivity();
468 activity.startVpnProfile(profileInfo);
469 }
470 };
471 DialogInterface.OnClickListener disconnectListener = new DialogInterface.OnClickListener()
472 {
473 @Override
474 public void onClick(DialogInterface dialog, int which)
475 {
476 VpnProfileControlActivity activity = (VpnProfileControlActivity)getActivity();
477 if (activity.mService != null)
478 {
479 activity.mService.disconnect();
480 }
481 activity.finish();
482 }
483 };
484 DialogInterface.OnClickListener cancelListener = new DialogInterface.OnClickListener()
485 {
486 @Override
487 public void onClick(DialogInterface dialog, int which)
488 {
489 getActivity().finish();
490 }
491 };
492
493 AlertDialog.Builder builder = new AlertDialog.Builder(getActivity())
494 .setIcon(icon)
495 .setTitle(String.format(getString(title), profileInfo.getString(PROFILE_NAME)))
496 .setMessage(message);
497
498 if (profileInfo.getBoolean(PROFILE_DISCONNECT))
499 {
500 builder.setPositiveButton(button, disconnectListener);
501 }
502 else
503 {
504 builder.setPositiveButton(button, connectListener);
505 }
506
507 if (profileInfo.getBoolean(PROFILE_RECONNECT))
508 {
509 builder.setNegativeButton(R.string.disconnect, disconnectListener);
510 builder.setNeutralButton(android.R.string.cancel, cancelListener);
511 }
512 else
513 {
514 builder.setNegativeButton(android.R.string.cancel, cancelListener);
515 }
516 return builder.create();
517 }
518
519 @Override
520 public void onCancel(DialogInterface dialog)
521 {
522 getActivity().finish();
523 }
524 }
525
526 /**
527 * Class that displays a login dialog and initiates the selected VPN
528 * profile if the user confirms the dialog.
529 */
530 public static class LoginDialog extends AppCompatDialogFragment
531 {
532 @Override
533 public Dialog onCreateDialog(Bundle savedInstanceState)
534 {
535 final Bundle profileInfo = getArguments();
536 LayoutInflater inflater = getActivity().getLayoutInflater();
537 View view = inflater.inflate(R.layout.login_dialog, null);
538 EditText username = (EditText)view.findViewById(R.id.username);
539 username.setText(profileInfo.getString(VpnProfileDataSource.KEY_USERNAME));
540 final EditText password = (EditText)view.findViewById(R.id.password);
541
542 AlertDialog.Builder adb = new AlertDialog.Builder(getActivity());
543 adb.setView(view);
544 adb.setTitle(getString(R.string.login_title));
545 adb.setPositiveButton(R.string.login_confirm, new DialogInterface.OnClickListener()
546 {
547 @Override
548 public void onClick(DialogInterface dialog, int whichButton)
549 {
550 VpnProfileControlActivity activity = (VpnProfileControlActivity)getActivity();
551 profileInfo.putString(VpnProfileDataSource.KEY_PASSWORD, password.getText().toString().trim());
552 activity.prepareVpnService(profileInfo);
553 }
554 });
555 adb.setNegativeButton(android.R.string.cancel, new DialogInterface.OnClickListener()
556 {
557 @Override
558 public void onClick(DialogInterface dialog, int which)
559 {
560 getActivity().finish();
561 }
562 });
563 return adb.create();
564 }
565
566 @Override
567 public void onCancel(DialogInterface dialog)
568 {
569 getActivity().finish();
570 }
571 }
572
573 /**
574 * Class that displays a warning before asking the user to add the app to the
575 * device's power whitelist.
576 */
577 public static class PowerWhitelistRequired extends AppCompatDialogFragment
578 {
579 @Override
580 public Dialog onCreateDialog(Bundle savedInstanceState)
581 {
582 return new AlertDialog.Builder(getActivity())
583 .setTitle(R.string.power_whitelist_title)
584 .setMessage(R.string.power_whitelist_text)
585 .setPositiveButton(android.R.string.ok, (dialog, id) -> {
586 Intent intent = new Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS,
587 Uri.parse("package:" + getActivity().getPackageName()));
588 getActivity().startActivityForResult(intent, ADD_TO_POWER_WHITELIST);
589 }).create();
590 }
591
592 @Override
593 public void onCancel(@NonNull DialogInterface dialog)
594 {
595 getActivity().finish();
596 }
597 }
598
599 /**
600 * Class representing an error message which is displayed if VpnService is
601 * not supported on the current device.
602 */
603 public static class VpnNotSupportedError extends AppCompatDialogFragment
604 {
605 static final String ERROR_MESSAGE_ID = "org.strongswan.android.VpnNotSupportedError.MessageId";
606
607 public static void showWithMessage(AppCompatActivity activity, int messageId)
608 {
609 Bundle bundle = new Bundle();
610 bundle.putInt(ERROR_MESSAGE_ID, messageId);
611 VpnNotSupportedError dialog = new VpnNotSupportedError();
612 dialog.setArguments(bundle);
613 dialog.show(activity.getSupportFragmentManager(), DIALOG_TAG);
614 }
615
616 @Override
617 public Dialog onCreateDialog(Bundle savedInstanceState)
618 {
619 final Bundle arguments = getArguments();
620 final int messageId = arguments.getInt(ERROR_MESSAGE_ID);
621 return new AlertDialog.Builder(getActivity())
622 .setTitle(R.string.vpn_not_supported_title)
623 .setMessage(messageId)
624 .setPositiveButton(android.R.string.ok, new DialogInterface.OnClickListener()
625 {
626 @Override
627 public void onClick(DialogInterface dialog, int id)
628 {
629 getActivity().finish();
630 }
631 }).create();
632 }
633
634 @Override
635 public void onCancel(DialogInterface dialog)
636 {
637 getActivity().finish();
638 }
639 }
640 }