diff --git a/app/libs/ca.psiphon.aar b/app/libs/ca.psiphon.aar index 162712512..f8ba1d64a 100644 --- a/app/libs/ca.psiphon.aar +++ b/app/libs/ca.psiphon.aar @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:4618570cc5a8bb59b3fa3b58332f49c28edc003860f851d41ac9dbe7d0e622b9 -size 37715892 +oid sha256:c2706a9dd1d090aaf38e313e654e0e68ae455ee0e120a86c6d20c8076171afef +size 37730522 diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 434fb2f73..bf2f571e9 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -64,6 +64,15 @@ android:scheme="psiphon" android:host="settings" /> + + + + + + + + + + @@ -159,41 +176,41 @@ android:process=":LoggingContentProvider" android:authorities="com.psiphon3.LoggingContentProvider" /> - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/java/com/psiphon3/HomeTabFragment.java b/app/src/main/java/com/psiphon3/HomeTabFragment.java index 48578efa5..df4576f24 100644 --- a/app/src/main/java/com/psiphon3/HomeTabFragment.java +++ b/app/src/main/java/com/psiphon3/HomeTabFragment.java @@ -35,6 +35,7 @@ import android.widget.TextView; import android.widget.ViewFlipper; +import androidx.annotation.DrawableRes; import androidx.annotation.NonNull; import androidx.annotation.Nullable; import androidx.fragment.app.Fragment; @@ -164,16 +165,20 @@ public void onDestroy() { } private void updateStatusUI(TunnelState tunnelState) { + @DrawableRes int statusIconResId; if (tunnelState.isRunning()) { if (tunnelState.connectionData().isConnected()) { - statusViewImage.setImageResource(R.drawable.status_icon_connected); + statusIconResId = tunnelState.connectionData().personalPairingEnabled() ? + R.drawable.status_icon_connected_pp : R.drawable.status_icon_connected; } else { - statusViewImage.setImageResource(R.drawable.status_icon_connecting); + statusIconResId = tunnelState.connectionData().personalPairingEnabled() ? + R.drawable.status_icon_connecting_pp : R.drawable.status_icon_connecting; } } else { // the tunnel state is either unknown or not running - statusViewImage.setImageResource(R.drawable.status_icon_disconnected); + statusIconResId = R.drawable.status_icon_disconnected; } + statusViewImage.setImageResource(statusIconResId); } private void loadEmbeddedWebView(String url) { diff --git a/app/src/main/java/com/psiphon3/MainActivity.java b/app/src/main/java/com/psiphon3/MainActivity.java index 802db2462..2e4b7ffe4 100644 --- a/app/src/main/java/com/psiphon3/MainActivity.java +++ b/app/src/main/java/com/psiphon3/MainActivity.java @@ -34,6 +34,9 @@ import android.nfc.cardemulation.CardEmulation; import android.os.Build; import android.os.Bundle; +import android.os.Handler; +import android.os.Looper; +import android.os.SystemClock; import android.provider.Settings; import android.text.SpannableString; import android.text.SpannableStringBuilder; @@ -41,6 +44,7 @@ import android.text.TextUtils; import android.text.style.BulletSpan; import android.text.util.Linkify; +import android.util.Pair; import android.view.Gravity; import android.view.LayoutInflater; import android.view.Menu; @@ -55,7 +59,9 @@ import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import androidx.annotation.StringRes; import androidx.appcompat.app.AlertDialog; +import androidx.appcompat.widget.SwitchCompat; import androidx.core.app.ActivityCompat; import androidx.core.content.ContextCompat; import androidx.core.content.PermissionChecker; @@ -69,8 +75,10 @@ import com.google.android.material.tabs.TabLayout; import com.psiphon3.VpnRulesHelper; import com.psiphon3.log.LogsMaintenanceWorker; +import com.psiphon3.log.MyLog; import com.psiphon3.psiphonlibrary.EmbeddedValues; import com.psiphon3.psiphonlibrary.LocalizedActivities; +import com.psiphon3.psiphonlibrary.PersonalPairingHelper; import com.psiphon3.psiphonlibrary.TunnelManager; import com.psiphon3.psiphonlibrary.Utils; import com.psiphon3.psiphonlibrary.VpnAppsUtils; @@ -90,11 +98,14 @@ import java.util.Locale; import java.util.Map; import java.util.Set; +import java.util.concurrent.TimeUnit; import io.reactivex.Completable; +import io.reactivex.Flowable; import io.reactivex.Maybe; import io.reactivex.android.schedulers.AndroidSchedulers; import io.reactivex.disposables.CompositeDisposable; +import io.reactivex.schedulers.Schedulers; public class MainActivity extends LocalizedActivities.AppCompatActivity { @@ -128,6 +139,17 @@ public MainActivity() { // Keeps track of the Psiphon Bump help state private PsiphonBumpHelpState psiphonBumpHelpState = PsiphonBumpHelpState.DISABLED; + private View personalPairingToggleContainer; + private SwitchCompat personalPairingToggle; + private TextView personalPairingLabel; + private Button personalPairingTurnOffButton; + private boolean personalPairingEnabled; + private TunnelState latestTunnelState; + private long personalPairingConnectingSinceMs = -1; + private static final long PERSONAL_PAIRING_TURN_OFF_PROMPT_DELAY_MS = TimeUnit.MINUTES.toMillis(2); + private final Handler personalPairingPromptHandler = new Handler(Looper.getMainLooper()); + private final Runnable personalPairingPromptRunnable = this::updatePersonalPairingTurnOffPrompt; + enum PsiphonBumpHelpState { DISABLED, NEED_SYSTEM_NFC, @@ -208,6 +230,22 @@ protected void onCreate(@Nullable Bundle savedInstanceState) { helpConnectFab = findViewById(R.id.help_connect_fab); + personalPairingToggleContainer = findViewById(R.id.personalPairingToggleContainer); + personalPairingToggle = findViewById(R.id.personalPairingToggle); + personalPairingToggle.setOnCheckedChangeListener((buttonView, isChecked) -> + viewModel.setPersonalParingEnabled(isChecked)); + + personalPairingLabel = findViewById(R.id.personalPairingLabel); + personalPairingTurnOffButton = findViewById(R.id.personalPairingTurnOffButton); + personalPairingTurnOffButton.setOnClickListener(v -> { + if (personalPairingToggle.isChecked()) { + personalPairingToggle.setChecked(false); + } else { + viewModel.setPersonalParingEnabled(false); + } + }); + + EmbeddedValues.initialize(getApplicationContext()); // Load VPN exclusion rules from storage for main app process @@ -301,6 +339,7 @@ public void onTabReselected(TabLayout.Tab tab) { @Override public void onDestroy() { + personalPairingPromptHandler.removeCallbacks(personalPairingPromptRunnable); compositeDisposable.dispose(); super.onDestroy(); } @@ -309,6 +348,7 @@ public void onDestroy() { protected void onPause() { super.onPause(); cancelInvalidProxySettingsToast(); + personalPairingPromptHandler.removeCallbacks(personalPairingPromptRunnable); compositeDisposable.clear(); } @@ -346,19 +386,8 @@ protected void onResume() { .doOnNext(this::updateServiceStateUI) .subscribe()); - // If device supports Psiphon Bump observe tunnel state and update NFC UI and HCE state accordingly - if (Utils.supportsPsiphonBump(this)) { - compositeDisposable.add(getTunnelServiceInteractor().tunnelStateFlowable() - .observeOn(AndroidSchedulers.mainThread()) - .doOnNext(this::updatePsiphonBumpState) - // disable Psiphon Bump HCE and hide help connect FAB when this subscription is - // disposed. - .doOnCancel(() -> { - updatePsiphonBumpHceState(false); - helpConnectFab.setVisibility(View.GONE); - }) - .subscribe()); - } + // Set up Psiphon Bump state handling + setupPsiphonBumpHandling(); // Observe custom proxy validation results to show a toast for invalid ones compositeDisposable.add(viewModel.customProxyValidationResultFlowable() @@ -397,6 +426,91 @@ protected void onResume() { .andThen(autoStartMaybe()) .doOnSuccess(__ -> startTunnel()) .subscribe()); + + // Observe personal pairing state changes and restart the tunnel if needed +// Observe personal pairing state changes and restart the tunnel if needed + compositeDisposable.add( + viewModel.pairingStateRestartTunnelFlowable() + .observeOn(AndroidSchedulers.mainThread()) + .switchMap(__ -> getTunnelServiceInteractor().tunnelStateFlowable() + .filter(tunnelState -> !tunnelState.isUnknown()) + .take(1) + .doOnNext(tunnelState -> { + if (tunnelState.isRunning()) { + getTunnelServiceInteractor().commandTunnelRestart(); + } + }) + ) + .subscribe()); + + // Observe personal paring state and update the UI + compositeDisposable.add( + viewModel.personalPairingStateFlowable() + .observeOn(AndroidSchedulers.mainThread()) + .doOnNext(state -> { + personalPairingEnabled = state.enabled; + + if (state.data == null || state.data.compartmentId == null || state.data.compartmentId.isEmpty()) { + // Hide the personal pairing toggle layout if there is no data + personalPairingToggleContainer.setVisibility(View.GONE); + } else { + // Show the personal pairing toggle layout if there is data + personalPairingToggleContainer.setVisibility(View.VISIBLE); + } + + if (state.enabled && state.data != null && state.data.compartmentId != null && !state.data.compartmentId.isEmpty()) { + String alias = state.data.alias; + personalPairingToggle.setChecked(true); + if (alias != null && !alias.isEmpty()) { + personalPairingLabel.setText(getString(R.string.preference_summary_personal_pairing_enabled_with_alias, alias)); + } else { + personalPairingLabel.setText(getString(R.string.preference_summary_personal_pairing_enabled)); + } + personalPairingLabel.setVisibility(View.VISIBLE); + } else { + personalPairingToggle.setChecked(false); + personalPairingLabel.setVisibility(View.INVISIBLE); // Not GONE, we want to keep the space + } + + updatePersonalPairingTurnOffPrompt(); + }) + .subscribe()); + } + + private void setupPsiphonBumpHandling() { + if (!Utils.supportsPsiphonBump(this)) { + updatePsiphonBumpHceState(false); + helpConnectFab.setVisibility(View.GONE); + helpConnectFab.setOnClickListener(null); + return; + } + + compositeDisposable.add( + Flowable.combineLatest( + getTunnelServiceInteractor().tunnelStateFlowable(), + viewModel.personalPairingStateFlowable(), + Pair::new) + .observeOn(AndroidSchedulers.mainThread()) + .doOnNext(statePair -> { + TunnelState tunnelState = statePair.first; + PersonalPairingHelper.PersonalPairingState personalPairingState = statePair.second; + + // If personal pairing is enabled, override everything else. + if (personalPairingState.enabled) { + updatePsiphonBumpHceState(false); + helpConnectFab.setVisibility(View.GONE); + helpConnectFab.setOnClickListener(null); + } else { + // Otherwise process normal tunnel state. + updatePsiphonBumpState(tunnelState); + } + }) + .doOnCancel(() -> { + updatePsiphonBumpHceState(false); + helpConnectFab.setVisibility(View.GONE); + }) + .subscribe() + ); } // Check runtime permissions and show rationales if needed. @@ -551,7 +665,47 @@ protected void onNewIntent(Intent intent) { HandleCurrentIntent(intent); } + private boolean shouldShowPersonalPairingTurnOffPrompt() { + if (!personalPairingEnabled || latestTunnelState == null || !latestTunnelState.isRunning()) { + return false; + } + + TunnelState.ConnectionData connectionData = latestTunnelState.connectionData(); + return connectionData != null + && connectionData.networkConnectionState() == TunnelState.ConnectionData.NetworkConnectionState.CONNECTING; + } + + private void hidePersonalPairingTurnOffPrompt() { + personalPairingConnectingSinceMs = -1; + personalPairingPromptHandler.removeCallbacks(personalPairingPromptRunnable); + personalPairingTurnOffButton.setVisibility(View.GONE); + } + + private void updatePersonalPairingTurnOffPrompt() { + if (!shouldShowPersonalPairingTurnOffPrompt()) { + hidePersonalPairingTurnOffPrompt(); + return; + } + + if (personalPairingConnectingSinceMs < 0) { + personalPairingConnectingSinceMs = SystemClock.elapsedRealtime(); + } + + long elapsed = SystemClock.elapsedRealtime() - personalPairingConnectingSinceMs; + long remaining = PERSONAL_PAIRING_TURN_OFF_PROMPT_DELAY_MS - elapsed; + + personalPairingPromptHandler.removeCallbacks(personalPairingPromptRunnable); + if (remaining <= 0) { + personalPairingTurnOffButton.setVisibility(View.VISIBLE); + } else { + personalPairingTurnOffButton.setVisibility(View.GONE); + personalPairingPromptHandler.postDelayed(personalPairingPromptRunnable, remaining); + } + } + private void updateServiceStateUI(final TunnelState tunnelState) { + latestTunnelState = tunnelState; + if (tunnelState.isUnknown()) { openBrowserButton.setEnabled(false); toggleButton.setEnabled(false); @@ -595,6 +749,8 @@ private void updateServiceStateUI(final TunnelState tunnelState) { connectionProgressBar.setVisibility(View.INVISIBLE); connectionWaitingNetworkIndicator.setVisibility(View.INVISIBLE); } + + updatePersonalPairingTurnOffPrompt(); } // update NFC UI @@ -848,6 +1004,7 @@ private void HandleCurrentIntent(Intent intent) { } } + // Handles deep links private boolean handleDeepLinkIntent(@NonNull Intent intent) { final String FWD_SLASH = "/"; @@ -857,40 +1014,165 @@ private boolean handleDeepLinkIntent(@NonNull Intent intent) { final String SETTINGS_PATH_VPN = "/vpn"; final String SETTINGS_PATH_PROXY = "/proxy"; final String SETTINGS_PATH_MORE_OPTIONS = "/more-options"; + final String PAIR_HOST = "pair"; Uri intentUri = intent.getData(); - // Check if this is a deep link intent we can handle - if (!Intent.ACTION_VIEW.equals(intent.getAction()) || - intentUri == null || - !PSIPHON_SCHEME.equals(intentUri.getScheme())) { - // Intent not handled + // Check if the intent is a view action and has a valid URI + if (!Intent.ACTION_VIEW.equals(intent.getAction()) || intentUri == null) { + // Intent was not handled return false; } + String scheme = intentUri.getScheme(); + String host = intentUri.getHost(); String path = intentUri.getPath(); - switch (intentUri.getHost()) { - case SETTINGS_HOST: - selectTabByTag("settings"); - if (path != null) { - // If uri path is "/vpn" or "/vpn/.*" then signal to navigate to VPN settings screen. - // If the path is "/proxy" or "/proxy/.*" then signal to navigate to Proxy settings screen. - // If the path is "/more-options" or "/more-options/.*" then signal to navigate to More Options screen. - if (path.equals(SETTINGS_PATH_VPN) || path.startsWith(SETTINGS_PATH_VPN + FWD_SLASH)) { - viewModel.signalOpenVpnSettings(); - } else if (path.equals(SETTINGS_PATH_PROXY) || path.startsWith(SETTINGS_PATH_PROXY + FWD_SLASH)) { - viewModel.signalOpenProxySettings(); - } else if (path.equals(SETTINGS_PATH_MORE_OPTIONS) || path.startsWith(SETTINGS_PATH_MORE_OPTIONS)) { - viewModel.signalOpenMoreOptions(); - } - } - // intent handled + // Handle Personal Pairing deep links (psiphon://pair/) + if (PSIPHON_SCHEME.equals(scheme) && PAIR_HOST.equals(host)) { + // Return false if path is missing or invalid + if (path == null || path.length() <= 1) { + // Intent was not handled + return false; + } + + if (!intentUri.getPathSegments().isEmpty()) { + handlePersonalPairingData(intentUri.toString()); + // Intent was handled return true; + } + MyLog.w("MainActivity::handleDeepLinkIntent: empty pairing data in deep link"); + // Intent was not handled + return false; + } + + // Finally, handle psiphon://settings/... deep links + if (SETTINGS_HOST.equals(host)) { + selectTabByTag("settings"); + // If uri path is "/vpn" or "/vpn/.*" then signal to navigate to VPN settings screen. + // If the path is "/proxy" or "/proxy/.*" then signal to navigate to Proxy settings screen. + // If the path is "/more-options" or "/more-options/.*" then signal to navigate to More Options screen. + if (path != null) { + if (path.equals(SETTINGS_PATH_VPN) || path.startsWith(SETTINGS_PATH_VPN + FWD_SLASH)) { + viewModel.signalOpenVpnSettings(); + } else if (path.equals(SETTINGS_PATH_PROXY) || path.startsWith(SETTINGS_PATH_PROXY + FWD_SLASH)) { + viewModel.signalOpenProxySettings(); + } else if (path.equals(SETTINGS_PATH_MORE_OPTIONS) || path.startsWith(SETTINGS_PATH_MORE_OPTIONS + FWD_SLASH)) { + viewModel.signalOpenMoreOptions(); + } + } + // Intent was handled + return true; } - // intent not handled + // Intent was not handled return false; } + // Handles personal pairing data import from deep links + private void handlePersonalPairingData(String input) { + Flowable tunnelStateFlowable = getTunnelServiceInteractor() + .tunnelStateFlowable() + .filter(state -> !state.isUnknown()); + + compositeDisposable.add( + viewModel.handlePersonalPairingData(input, tunnelStateFlowable) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe(result -> { + switch (result.action) { + case SHOW_SUCCESS: + showToast(R.string.personal_pairing_data_import_success); + break; + case SHOW_ALREADY_EXISTS: + showToast(R.string.personal_pairing_data_already_exists); + break; + case SHOW_ERROR: + showToast(getPairingImportErrorString(result.validationError)); + break; + case PROMPT_ENABLE: + showEnableConfirmationDialog(result.data); + break; + case PROMPT_UPDATE: + showUpdateConfirmationDialog(result.data, result.existingCompartmentId, result.existingEnabled); + break; + } + }, error -> showToast(getPairingImportErrorString( + PersonalPairingHelper.validationErrorFromException(error)))) + ); + } + + @StringRes + private int getPairingImportErrorString(PersonalPairingHelper.ImportValidationError validationError) { + if (validationError == PersonalPairingHelper.ImportValidationError.UNSUPPORTED_VERSION) { + return R.string.personal_pairing_unsupported_version; + } + if (validationError == PersonalPairingHelper.ImportValidationError.INVALID_INPUT_FORMAT) { + return R.string.personal_pairing_invalid_url; + } + return R.string.personal_pairing_invalid_data; + } + + // Keeps track of any import pairing data toast to cancel if we need to show a new one + Toast importPairingDataToast; + + // Shows a toast while cancelling any current import pairing data toast + private void showToast(@StringRes int messageId) { + if (importPairingDataToast != null) { + importPairingDataToast.cancel(); + } + importPairingDataToast = Toast.makeText(MainActivity.this, messageId, Toast.LENGTH_LONG); + importPairingDataToast.show(); + } + + // Keep track of the update confirmation dialog to dismiss if we need to show a new one + AlertDialog updateConfirmationDialog; + // Confirms updating existing personal pairing data if the compartment ID already present in the settings + private void showUpdateConfirmationDialog(PersonalPairingHelper.PersonalPairingData newData, String existingId, boolean enabled) { + if (updateConfirmationDialog != null && updateConfirmationDialog.isShowing()) { + updateConfirmationDialog.dismiss(); + } + View dialogView = getLayoutInflater().inflate(R.layout.dialog_pairing_update, null); + TextView oldIdView = dialogView.findViewById(R.id.old_compartment_id); + TextView newIdView = dialogView.findViewById(R.id.new_compartment_id); + oldIdView.setText(existingId); + newIdView.setText(newData.compartmentId); + + updateConfirmationDialog = new AlertDialog.Builder(this) + .setIcon(R.drawable.ic_psiphon_alert_notification) + .setTitle(R.string.personal_pairing_update_title) + .setView(dialogView) + .setPositiveButton(R.string.personal_pairing_update_positive_button, + (dialog, which) -> { + viewModel.confirmPersonalPairingImport(newData, enabled); + showToast(R.string.personal_pairing_data_update_success); + }) + .setNegativeButton(R.string.personal_pairing_update_negative_button, null) + .show(); + } + + // Keep track of the enable confirmation dialog to dismiss if we need to show a new one + AlertDialog enableConfirmationDialog; + // Confirms enabling personal pairing feature while importing personal pairing data + private void showEnableConfirmationDialog(PersonalPairingHelper.PersonalPairingData data) { + if (enableConfirmationDialog != null && enableConfirmationDialog.isShowing()) { + enableConfirmationDialog.dismiss(); + } + View dialogView = getLayoutInflater().inflate(R.layout.dialog_pairing_enable, null); + + enableConfirmationDialog = new AlertDialog.Builder(this) + .setIcon(R.drawable.ic_psiphon_alert_notification) + .setTitle(R.string.personal_pairing_enable_confirmation_dialog_title) + .setView(dialogView) + .setPositiveButton(R.string.lbl_yes, (dialog, which) -> { + viewModel.confirmPersonalPairingImport(data, true); + showToast(R.string.personal_pairing_data_import_success); + }) + .setNegativeButton(R.string.lbl_no, (dialog, which) -> { + viewModel.confirmPersonalPairingImport(data, false); + showToast(R.string.personal_pairing_data_import_success); + }) + .show(); + } + @Override public void startTunnel() { // Don't start if custom proxy settings is selected and values are invalid @@ -916,8 +1198,15 @@ private void preventAutoStart() { } private boolean shouldAutoStart() { + Intent intent = getIntent(); + + // Check if launched from a deep link or app link and prevent auto-start if so + boolean isDeepLink = Intent.ACTION_VIEW.equals(intent.getAction()) && + (intent.getData() != null); + return isFirstRun && - !getIntent().getBooleanExtra(INTENT_EXTRA_PREVENT_AUTO_START, false); + !intent.getBooleanExtra(INTENT_EXTRA_PREVENT_AUTO_START, false) && + !isDeepLink; } // Returns an object only if tunnel should be auto-started, diff --git a/app/src/main/java/com/psiphon3/MainActivityViewModel.java b/app/src/main/java/com/psiphon3/MainActivityViewModel.java index d8cc5ad4b..f5b7f87a3 100644 --- a/app/src/main/java/com/psiphon3/MainActivityViewModel.java +++ b/app/src/main/java/com/psiphon3/MainActivityViewModel.java @@ -34,10 +34,15 @@ import com.psiphon3.log.LogsDataSourceFactory; import com.psiphon3.log.LogsLastEntryHelper; import com.psiphon3.log.MyLog; +import com.psiphon3.psiphonlibrary.PersonalPairingHelper; import com.psiphon3.psiphonlibrary.UpstreamProxySettings; +import java.util.Objects; + import io.reactivex.BackpressureStrategy; import io.reactivex.Flowable; +import io.reactivex.Single; +import io.reactivex.disposables.CompositeDisposable; public class MainActivityViewModel extends AndroidViewModel implements DefaultLifecycleObserver { private final PublishRelay customProxyValidationResultRelay = PublishRelay.create(); @@ -49,9 +54,12 @@ public class MainActivityViewModel extends AndroidViewModel implements DefaultLi private final Flowable lastLogEntryFlowable; private final Flowable> logsPagedListFlowable; private final ContentObserver loggingObserver; + private final PersonalPairingHelper personalPairingHelper; + private final CompositeDisposable compositeDisposable = new CompositeDisposable(); public MainActivityViewModel(@NonNull Application application) { super(application); + personalPairingHelper = new PersonalPairingHelper(application); LogsLastEntryHelper logsLastEntryHelper = new LogsLastEntryHelper(application.getContentResolver()); LogsDataSourceFactory logsDataSourceFactory = new LogsDataSourceFactory(application.getContentResolver()); @@ -92,6 +100,7 @@ public void onChange(boolean selfChange) { @Override protected void onCleared() { super.onCleared(); + compositeDisposable.clear(); getApplication().getContentResolver().unregisterContentObserver(loggingObserver); } @@ -166,4 +175,58 @@ public Flowable lastLogEntryFlowable() { return lastLogEntryFlowable .map(logEntry -> MyLog.getStatusLogMessageForDisplay(logEntry.getLogJson(), getApplication())); } + + public Flowable personalPairingStateFlowable() { + return personalPairingHelper.observePersonalPairingState() + .distinctUntilChanged(); + } + + public Single handlePersonalPairingData( + String input, + Flowable tunnelState) { + return personalPairingHelper.handleImport(input, tunnelState); + } + + public void confirmPersonalPairingImport(PersonalPairingHelper.PersonalPairingData data, boolean enableSetting) { + personalPairingHelper.confirmImport(data, enableSetting); + } + + public void setPersonalPairingState(boolean isEnabled, @NonNull PersonalPairingHelper.PersonalPairingData data) { + personalPairingHelper.setPersonalPairingState(isEnabled, data); + } + + public void setPersonalParingEnabled(boolean isEnabled) { + personalPairingHelper.setPersonalPairingEnabled(isEnabled); + } + + // Keep track of the last known pairing state to determine when tunnel restart is needed + private PersonalPairingHelper.PersonalPairingState lastKnownPersonalPairingState; + + + // Observes pairing state changes and triggers tunnel restart when necessary. Restart occurs when: + // - Pairing is enabled/ disabled + // - Compartment ID changes while pairing is enabled + // returns a flowable that emits true when a restart is needed + public Flowable pairingStateRestartTunnelFlowable() { + return personalPairingStateFlowable() + .map(currentState -> { + boolean shouldRestart = false; + + if (lastKnownPersonalPairingState != null) { + boolean enableChanged = lastKnownPersonalPairingState.enabled != currentState.enabled; + String previousCompartmentId = + lastKnownPersonalPairingState.data == null ? null : lastKnownPersonalPairingState.data.compartmentId; + String currentCompartmentId = + currentState.data == null ? null : currentState.data.compartmentId; + boolean compartmentChanged = lastKnownPersonalPairingState.enabled && currentState.enabled && + !Objects.equals(previousCompartmentId, currentCompartmentId); + + shouldRestart = enableChanged || compartmentChanged; + } + + lastKnownPersonalPairingState = currentState; + return shouldRestart; + }) + .switchMap(shouldRestart -> shouldRestart ? Flowable.just(true) : Flowable.empty()); + } } diff --git a/app/src/main/java/com/psiphon3/OptionsTabFragment.java b/app/src/main/java/com/psiphon3/OptionsTabFragment.java index 37d7e18c7..d7c3285e3 100644 --- a/app/src/main/java/com/psiphon3/OptionsTabFragment.java +++ b/app/src/main/java/com/psiphon3/OptionsTabFragment.java @@ -17,6 +17,8 @@ import com.psiphon3.psiphonlibrary.LocalizedActivities; import com.psiphon3.psiphonlibrary.MoreOptionsPreferenceActivity; +import com.psiphon3.psiphonlibrary.PersonalPairingHelper; +import com.psiphon3.psiphonlibrary.PersonalPairingPreferenceActivity; import com.psiphon3.psiphonlibrary.ProxyOptionsPreferenceActivity; import com.psiphon3.psiphonlibrary.PsiphonConstants; import com.psiphon3.psiphonlibrary.PsiphonPreferenceFragmentCompat; @@ -42,10 +44,12 @@ private enum RestartMode { private static final int REQUEST_CODE_VPN_PREFERENCES = 100; private static final int REQUEST_CODE_PROXY_PREFERENCES = 101; private static final int REQUEST_CODE_MORE_PREFERENCES = 102; + private static final int REQUEST_CODE_PERSONAL_PAIRING = 103; private RegionListPreference regionListPreference; private Preference vpnOptionsPreference; private Preference proxyOptionsPreference; + private Preference personalPairingPreference; private AppPreferences multiProcessPreferences; private MainActivityViewModel viewModel; private final CompositeDisposable compositeDisposable = new CompositeDisposable(); @@ -105,6 +109,15 @@ public void onCreatePreferences(Bundle bundle, String s) { } return true; }); + personalPairingPreference = findPreference(getContext().getString(R.string.personalPairingPreferenceKey)); + personalPairingPreference.setOnPreferenceClickListener(__ -> { + final FragmentActivity activity = getActivity(); + if (activity != null && !activity.isFinishing()) { + startActivityForResult(new Intent(getActivity(), + PersonalPairingPreferenceActivity.class), REQUEST_CODE_PERSONAL_PAIRING); + } + return true; + }); } @Override @@ -167,6 +180,13 @@ public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceStat } }) .subscribe()); + + // Observe 'Personal Pairing` signal from deep link intent handler + // and update the preference summary + compositeDisposable.add(viewModel.personalPairingStateFlowable() + .observeOn(AndroidSchedulers.mainThread()) + .doOnNext(this::setPersonalPairingSummary) + .subscribe()); } private void onRegionSelected(String selectedRegionCode) { @@ -218,6 +238,21 @@ private void setSummaryFromPreferences() { } } + private void setPersonalPairingSummary(PersonalPairingHelper.PersonalPairingState personalPairingState) { + if (personalPairingState.enabled) { + String alias = personalPairingState.data == null ? null : personalPairingState.data.alias; + if (alias == null || alias.isEmpty()) { + // If no alias show just Enabled + personalPairingPreference.setSummary(R.string.preference_summary_personal_pairing_enabled); + } else { + // If alias then show the alias + personalPairingPreference.setSummary(getString(R.string.preference_summary_personal_pairing_enabled_with_alias, alias)); + } + } else { + personalPairingPreference.setSummary(R.string.preference_summary_personal_pairing_disabled); + } + } + @Override public void onActivityResult(int request, int result, Intent data) { final @NonNull RestartMode restartMode; @@ -237,6 +272,14 @@ public void onActivityResult(int request, int result, Intent data) { updateMoreSettingsFromPreferences(); break; + case REQUEST_CODE_PERSONAL_PAIRING: + // Note: we are not checking for restart requirements here + // because the personal pairing settings may be changed from the main screen or by importing data from a deep link as well as from the personal pairing settings screen + // and we will be taking care of the restart requirements centrally + restartMode = RestartMode.NONE; + updatePersonalPairingFromPreferences(); + break; + default: restartMode = RestartMode.NONE; super.onActivityResult(request, result, data); @@ -450,4 +493,17 @@ private void updateMoreSettingsFromPreferences() { new SharedPreferencesImport(requireContext(), prefName, getString(R.string.nfcBumpPreference), getString(R.string.nfcBumpPreference)) ); } + + private void updatePersonalPairingFromPreferences() { + String prefName = getString(R.string.moreOptionsPreferencesName); + boolean isEnabled = requireContext().getSharedPreferences(prefName, MODE_PRIVATE) + .getBoolean(getString(R.string.personalPairingEnabledPreference), false); + String compartmentId = requireContext().getSharedPreferences(prefName, MODE_PRIVATE) + .getString(getString(R.string.personalPairingCompartmentIdPreference), ""); + String alias = requireContext().getSharedPreferences(prefName, MODE_PRIVATE) + .getString(getString(R.string.personalPairingAliasPreference), ""); + + PersonalPairingHelper.PersonalPairingData data = new PersonalPairingHelper.PersonalPairingData(compartmentId, alias); + viewModel.setPersonalPairingState(isEnabled, data); + } } diff --git a/app/src/main/java/com/psiphon3/TunnelState.java b/app/src/main/java/com/psiphon3/TunnelState.java index 841e48d14..f3867f4c5 100644 --- a/app/src/main/java/com/psiphon3/TunnelState.java +++ b/app/src/main/java/com/psiphon3/TunnelState.java @@ -62,6 +62,8 @@ public enum NetworkConnectionState { @Nullable public abstract ArrayList vpnApps(); + public abstract boolean personalPairingEnabled(); + public static Builder builder() { return new AutoValue_TunnelState_ConnectionData.Builder() .setNetworkConnectionState(NetworkConnectionState.CONNECTING) @@ -72,7 +74,8 @@ public static Builder builder() { .setHttpPort(0) .setHomePages(null) .setVpnMode(com.psiphon3.psiphonlibrary.VpnAppsUtils.VpnAppsExclusionSetting.ALL_APPS) - .setVpnApps(null); + .setVpnApps(null) + .setPersonalPairingEnabled(false); } @AutoValue.Builder @@ -95,6 +98,8 @@ public static abstract class Builder { public abstract Builder setVpnApps(@Nullable ArrayList vpnApps); + public abstract Builder setPersonalPairingEnabled(boolean value); + public abstract ConnectionData build(); } diff --git a/app/src/main/java/com/psiphon3/psiphonlibrary/PersonalPairingHelper.java b/app/src/main/java/com/psiphon3/psiphonlibrary/PersonalPairingHelper.java new file mode 100644 index 000000000..6e5a8f104 --- /dev/null +++ b/app/src/main/java/com/psiphon3/psiphonlibrary/PersonalPairingHelper.java @@ -0,0 +1,565 @@ +/* + * Copyright (c) 2024, Psiphon Inc. + * All rights reserved. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package com.psiphon3.psiphonlibrary; + +import android.content.Context; + +import androidx.annotation.NonNull; + +import com.fasterxml.jackson.core.JsonFactory; +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.core.JsonToken; +import com.jakewharton.rxrelay2.BehaviorRelay; +import com.psiphon3.R; +import com.psiphon3.TunnelState; +import com.psiphon3.log.MyLog; + +import net.grandcentrix.tray.AppPreferences; + +import java.io.IOException; +import java.net.URI; +import java.net.URISyntaxException; +import java.util.Objects; +import java.util.regex.Pattern; + +import io.reactivex.BackpressureStrategy; +import io.reactivex.Flowable; +import io.reactivex.Single; + +/** + * Helper class to manage the state and configuration of the personal pairing feature. + * Provides utilities to observe, validate, and update personal pairing settings, + * handle user imports, and manage storage and relay mechanisms for state changes. + */ +public class PersonalPairingHelper { + private static final String PSIPHON_SCHEME = "psiphon"; + private static final String PSIPHON_PAIR_HOST = "pair"; + private static final String HTTP_SCHEME = "http"; + private static final String HTTPS_SCHEME = "https"; + private static final String PAIR_PATH_SEGMENT = "pair"; + private static final String SUPPORTED_VERSION = "1"; + private static final String VERSION_KEY = "v"; + private static final String DATA_KEY = "data"; + private static final String ID_KEY = "id"; + private static final String NAME_KEY = "name"; + private static final Pattern BASE64URL_PATTERN = Pattern.compile("^[A-Za-z0-9_-]+$"); + private static final Pattern BASE64_PATTERN = Pattern.compile("^[A-Za-z0-9+/]+={0,2}$"); + private static final Pattern COMPARTMENT_ID_STANDARD_PATTERN = Pattern.compile("^[A-Za-z0-9+/]{43}$"); + private static final JsonFactory JSON_FACTORY = new JsonFactory(); + + public enum ImportValidationError { + INVALID_INPUT_FORMAT, + MALFORMED_TOKEN, + UNSUPPORTED_VERSION + } + + public static class PersonalPairingImportException extends IllegalArgumentException { + public final ImportValidationError validationError; + + public PersonalPairingImportException(ImportValidationError validationError) { + super(validationError.name()); + this.validationError = validationError; + } + + public PersonalPairingImportException(ImportValidationError validationError, Throwable cause) { + super(validationError.name(), cause); + this.validationError = validationError; + } + } + + public static class PersonalPairingState { + public final boolean enabled; + public final PersonalPairingData data; + + public PersonalPairingState(boolean enabled, PersonalPairingData data) { + this.enabled = enabled; + this.data = data; + } + + public static PersonalPairingState create(boolean enabled, PersonalPairingData data) { + return new PersonalPairingState(enabled, data); + } + + public PersonalPairingState withEnabled(boolean enabled) { + return new PersonalPairingState(enabled, this.data); + } + + public PersonalPairingState withData(PersonalPairingData data) { + return new PersonalPairingState(this.enabled, data); + } + } + + public static class PersonalPairingData { + public final String compartmentId; + public final String alias; + + public PersonalPairingData(String compartmentId, String alias) { + this.compartmentId = compartmentId; + this.alias = alias; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + PersonalPairingData that = (PersonalPairingData) o; + return Objects.equals(compartmentId, that.compartmentId) && + Objects.equals(alias, that.alias); + } + + @Override + public int hashCode() { + return Objects.hash(compartmentId, alias); + } + } + + private final BehaviorRelay personalPairingStateRelay; + private final AppPreferences prefs; + private final Context context; + + public static String toStandardBase64CompartmentId(String compartmentId) { + if (compartmentId == null) { + return null; + } + return compartmentId + .replace('-', '+') + .replace('_', '/'); + } + + public PersonalPairingHelper(Context context) { + this.context = context; + this.prefs = new AppPreferences(context); + this.personalPairingStateRelay = BehaviorRelay.createDefault(loadInitialState()); + } + + // Load initial state from multi-process shared preferences + private PersonalPairingState loadInitialState() { + boolean enabled = prefs.getBoolean(context.getString(R.string.personalPairingEnabledPreference), false); + String compartmentId = prefs.getString( + context.getString(R.string.personalPairingCompartmentIdPreference), ""); + String alias = prefs.getString( + context.getString(R.string.personalPairingAliasPreference), ""); + + PersonalPairingData data = null; + if (compartmentId != null && !compartmentId.isEmpty()) { + data = new PersonalPairingData(toStandardBase64CompartmentId(compartmentId), alias != null ? alias : ""); + } + + return new PersonalPairingState(enabled, data); + } + + // Observe personal pairing state changes + public Flowable observePersonalPairingState() { + return personalPairingStateRelay.hide() + .toFlowable(BackpressureStrategy.LATEST); + } + + // Update personal pairing state enabled flag + public void setPersonalPairingEnabled(boolean enabled) { + PersonalPairingState currentState = personalPairingStateRelay.getValue(); + if (currentState != null && currentState.enabled != enabled) { + prefs.put(context.getString(R.string.personalPairingEnabledPreference), enabled); + personalPairingStateRelay.accept(currentState.withEnabled(enabled)); + } + } + + // Update personal pairing state data, i.e. compartment ID and alias values and enabled flag + public void setPersonalPairingState(boolean enabled, PersonalPairingData data) { + if (data == null) { + return; + } + + PersonalPairingData normalizedData = new PersonalPairingData( + toStandardBase64CompartmentId(data.compartmentId), + data.alias); + + PersonalPairingState currentState = personalPairingStateRelay.getValue(); + if (currentState != null && (currentState.enabled != enabled || + !Objects.equals(currentState.data, normalizedData))) { + prefs.put(context.getString(R.string.personalPairingEnabledPreference), enabled); + prefs.put(context.getString(R.string.personalPairingCompartmentIdPreference), normalizedData.compartmentId); + prefs.put(context.getString(R.string.personalPairingAliasPreference), normalizedData.alias); + personalPairingStateRelay.accept(PersonalPairingState.create(enabled, normalizedData)); + } + } + + // Container class for import result and data + public static class ImportResult { + public enum Action { + // Data imported successfully + SHOW_SUCCESS, + // Data already exists (same compartment ID) + SHOW_ALREADY_EXISTS, + // Data import failed + SHOW_ERROR, + // Prompt user to enable the feature + PROMPT_ENABLE, + // Prompt user to update existing data + PROMPT_UPDATE + } + + public final Action action; + public final PersonalPairingData data; + public final String existingCompartmentId; + public final Boolean existingEnabled; + public final ImportValidationError validationError; + + private ImportResult(Action action, + PersonalPairingData data, + String existingCompartmentId, + Boolean existingEnabled, + ImportValidationError validationError) { + this.action = action; + this.data = data; + this.existingCompartmentId = existingCompartmentId; + this.existingEnabled = existingEnabled; + this.validationError = validationError; + } + + public static ImportResult success(PersonalPairingData data) { + return new ImportResult(Action.SHOW_SUCCESS, data, null, null, null); + } + + public static ImportResult alreadyExists(PersonalPairingData data) { + return new ImportResult(Action.SHOW_ALREADY_EXISTS, data, null, null, null); + } + + public static ImportResult error(ImportValidationError validationError) { + return new ImportResult(Action.SHOW_ERROR, null, null, null, validationError); + } + + public static ImportResult promptEnable(PersonalPairingData data) { + return new ImportResult(Action.PROMPT_ENABLE, data, null, null, null); + } + + public static ImportResult needsUpdate(PersonalPairingData data, String existingId, Boolean existingEnabled) { + return new ImportResult(Action.PROMPT_UPDATE, data, existingId, existingEnabled, null); + } + } + + public static ImportValidationError validationErrorFromException(Throwable throwable) { + if (throwable instanceof PersonalPairingImportException) { + return ((PersonalPairingImportException) throwable).validationError; + } + return ImportValidationError.MALFORMED_TOKEN; + } + + private static PersonalPairingImportException invalidInputFormat() { + return new PersonalPairingImportException(ImportValidationError.INVALID_INPUT_FORMAT); + } + + private static PersonalPairingImportException malformedToken() { + return new PersonalPairingImportException(ImportValidationError.MALFORMED_TOKEN); + } + + private static PersonalPairingImportException malformedToken(Throwable cause) { + return new PersonalPairingImportException(ImportValidationError.MALFORMED_TOKEN, cause); + } + + private static PersonalPairingImportException unsupportedVersion() { + return new PersonalPairingImportException(ImportValidationError.UNSUPPORTED_VERSION); + } + + private static String normalizeTokenInput(String input) { + if (input == null) { + throw invalidInputFormat(); + } + + String trimmedInput = input.trim(); + if (trimmedInput.isEmpty()) { + throw invalidInputFormat(); + } + + if (!trimmedInput.contains("://")) { + return trimmedInput; + } + + URI uri; + try { + uri = new URI(trimmedInput); + } catch (URISyntaxException e) { + throw invalidInputFormat(); + } + + String scheme = uri.getScheme(); + if (scheme == null || scheme.isEmpty()) { + throw invalidInputFormat(); + } + + String[] segments = getRawPathSegments(uri.getRawPath()); + + if (PSIPHON_SCHEME.equals(scheme)) { + if (!PSIPHON_PAIR_HOST.equals(uri.getHost())) { + throw invalidInputFormat(); + } + if (segments.length != 1) { + throw invalidInputFormat(); + } + String token = segments[0]; + if (token == null || token.isEmpty()) { + throw invalidInputFormat(); + } + return token; + } + + if (HTTP_SCHEME.equals(scheme) || HTTPS_SCHEME.equals(scheme)) { + return extractTokenFromPairPath(segments); + } + + throw invalidInputFormat(); + } + + private static String extractTokenFromPairPath(String[] segments) { + for (int i = segments.length - 2; i >= 0; i--) { + if (PAIR_PATH_SEGMENT.equals(segments[i])) { + if (i + 2 != segments.length) { + throw invalidInputFormat(); + } + String token = segments[i + 1]; + if (token == null || token.isEmpty()) { + throw invalidInputFormat(); + } + return token; + } + } + throw invalidInputFormat(); + } + + private static String[] getRawPathSegments(String rawPath) { + if (rawPath == null || rawPath.isEmpty() || "/".equals(rawPath)) { + return new String[0]; + } + if (!rawPath.startsWith("/")) { + throw invalidInputFormat(); + } + String[] segments = rawPath.substring(1).split("/", -1); + for (String segment : segments) { + if (segment.isEmpty()) { + throw invalidInputFormat(); + } + } + return segments; + } + + private static byte[] decodeToken(String token) { + if (token == null || token.isEmpty()) { + throw malformedToken(); + } + + if (BASE64URL_PATTERN.matcher(token).matches()) { + try { + return decodeUrlSafeBase64(token); + } catch (IllegalArgumentException e) { + throw malformedToken(e); + } + } + + if (BASE64_PATTERN.matcher(token).matches() && token.length() % 4 == 0) { + try { + return Utils.Base64.decode(token); + } catch (IllegalArgumentException e) { + throw malformedToken(e); + } + } + + throw malformedToken(); + } + + private static String requireNonEmptyString(JsonParser parser) throws IOException { + if (parser.getCurrentToken() != JsonToken.VALUE_STRING) { + throw malformedToken(); + } + String stringValue = parser.getValueAsString(); + if (stringValue.isEmpty()) { + throw malformedToken(); + } + return stringValue; + } + + private static PersonalPairingData parsePayload(byte[] decodedToken) { + try (JsonParser parser = JSON_FACTORY.createParser(decodedToken)) { + if (parser.nextToken() != JsonToken.START_OBJECT) { + throw malformedToken(); + } + + String version = null; + String compartmentId = null; + String alias = null; + int topLevelFieldCount = 0; + + while (parser.nextToken() != JsonToken.END_OBJECT) { + if (parser.getCurrentToken() != JsonToken.FIELD_NAME) { + throw malformedToken(); + } + + String fieldName = parser.getCurrentName(); + topLevelFieldCount++; + parser.nextToken(); + + if (VERSION_KEY.equals(fieldName)) { + version = requireNonEmptyString(parser); + } else if (DATA_KEY.equals(fieldName)) { + if (parser.getCurrentToken() != JsonToken.START_OBJECT) { + throw malformedToken(); + } + + int dataFieldCount = 0; + while (parser.nextToken() != JsonToken.END_OBJECT) { + if (parser.getCurrentToken() != JsonToken.FIELD_NAME) { + throw malformedToken(); + } + + String dataFieldName = parser.getCurrentName(); + dataFieldCount++; + parser.nextToken(); + + if (ID_KEY.equals(dataFieldName)) { + compartmentId = requireNonEmptyString(parser); + } else if (NAME_KEY.equals(dataFieldName)) { + alias = requireNonEmptyString(parser); + } else { + throw malformedToken(); + } + } + + if (dataFieldCount != 2 || compartmentId == null || alias == null) { + throw malformedToken(); + } + } else { + throw malformedToken(); + } + } + + if (topLevelFieldCount != 2 || version == null) { + throw malformedToken(); + } + + if (!SUPPORTED_VERSION.equals(version)) { + throw unsupportedVersion(); + } + + String normalizedCompartmentId = validateAndNormalizeCompartmentId(compartmentId); + return new PersonalPairingData(normalizedCompartmentId, alias); + } catch (PersonalPairingImportException e) { + throw e; + } catch (IOException | RuntimeException e) { + throw malformedToken(e); + } + } + + private static String validateAndNormalizeCompartmentId(String compartmentId) { + if (!COMPARTMENT_ID_STANDARD_PATTERN.matcher(compartmentId).matches()) { + throw malformedToken(); + } + + String normalizedCompartmentId = toStandardBase64CompartmentId(compartmentId); + + try { + byte[] decoded = decodeUrlSafeBase64(normalizedCompartmentId); + if (decoded.length != 32) { + throw malformedToken(); + } + } catch (IllegalArgumentException e) { + throw malformedToken(e); + } + + return normalizedCompartmentId; + } + + private static byte[] decodeUrlSafeBase64(String token) { + int padLength = (4 - (token.length() % 4)) % 4; + String paddedToken = token + "====".substring(0, padLength); + String normalized = paddedToken + .replace('-', '+') + .replace('_', '/'); + return Utils.Base64.decode(normalized); + } + + // Extract personal pairing data from a token string, deep link, or wrapper URL + public static PersonalPairingData extractPersonalPairingData(String input) throws IllegalArgumentException { + String token = normalizeTokenInput(input); + try { + byte[] decodedToken = decodeToken(token); + return parsePayload(decodedToken); + } catch (PersonalPairingImportException e) { + throw e; + } catch (Exception e) { + throw malformedToken(e); + } + } + + // Validate personal pairing data and determine the appropriate action + private ImportResult validatePersonalPairingData(String input) { + try { + PersonalPairingData personalPairingData = extractPersonalPairingData(input); + String storedCompartmentId = prefs.getString(context.getString(R.string.personalPairingCompartmentIdPreference), ""); + Boolean storedEnabled = prefs.getBoolean(context.getString(R.string.personalPairingEnabledPreference), false); + if (storedCompartmentId == null || storedCompartmentId.isEmpty()) { + return ImportResult.promptEnable(personalPairingData); + } else if (storedCompartmentId.equals(personalPairingData.compartmentId)) { + return ImportResult.alreadyExists(personalPairingData); + } else { + return ImportResult.needsUpdate(personalPairingData, storedCompartmentId, storedEnabled); + } + } catch (IllegalArgumentException e) { + ImportValidationError validationError = validationErrorFromException(e); + MyLog.e("PersonalPairingHelper::validatePersonalPairingData error: " + validationError.name()); + return ImportResult.error(validationError); + } + } + + // Handle the import of personal pairing data import and determine the appropriate action + public Single handleImport(@NonNull String input, Flowable tunnelState) { + return Single.fromCallable(() -> validatePersonalPairingData(input)) + .flatMap(result -> { + if (result.action == ImportResult.Action.PROMPT_ENABLE) { + // If importing a new pairing while the tunnel is running, prompt the user to enable + // the feature because enabling the feature will restart the tunnel + // Otherwise, enable the feature automatically without prompting + return tunnelState + .firstOrError() + .map(state -> { + if (state.isRunning()) { + // Pass through the PROMPT_ENABLE result to trigger the prompt UI + return result; + } else { + // Enable the feature automatically, pass SHOW_SUCCESS result to trigger import success UI + setPersonalPairingState(true, result.data); + return ImportResult.success(result.data); + } + }); + } + return Single.just(result); + }); + } + + // Save the personal pairing data after user confirmation and sets the feature enabled flag + public void confirmImport(PersonalPairingData data, boolean enableSetting) { + setPersonalPairingState(enableSetting, data); + } + + // Reset all personal pairing preferences + public static void resetPersonalPairingPreferences(Context context) { + AppPreferences prefs = new AppPreferences(context); + prefs.remove(context.getString(R.string.personalPairingEnabledPreference)); + prefs.remove(context.getString(R.string.personalPairingCompartmentIdPreference)); + prefs.remove(context.getString(R.string.personalPairingAliasPreference)); + } +} diff --git a/app/src/main/java/com/psiphon3/psiphonlibrary/PersonalPairingPreferenceActivity.java b/app/src/main/java/com/psiphon3/psiphonlibrary/PersonalPairingPreferenceActivity.java new file mode 100644 index 000000000..8ba57aca9 --- /dev/null +++ b/app/src/main/java/com/psiphon3/psiphonlibrary/PersonalPairingPreferenceActivity.java @@ -0,0 +1,309 @@ +/* + * Copyright (c) 2020, Psiphon Inc. + * All rights reserved. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + */ + +package com.psiphon3.psiphonlibrary; + +import android.app.AlertDialog; +import android.content.SharedPreferences; +import android.os.Bundle; +import android.text.InputFilter; +import android.text.TextUtils; +import android.view.LayoutInflater; +import android.view.View; +import android.view.inputmethod.EditorInfo; +import android.widget.Button; +import android.widget.EditText; +import android.widget.TextView; +import android.widget.Toast; + +import androidx.annotation.Nullable; +import androidx.annotation.StringRes; +import androidx.lifecycle.ViewModelProvider; +import androidx.preference.CheckBoxPreference; +import androidx.preference.EditTextPreference; +import androidx.preference.Preference; +import androidx.preference.PreferenceScreen; + +import com.psiphon3.MainActivityViewModel; +import com.psiphon3.R; + +public class PersonalPairingPreferenceActivity extends LocalizedActivities.AppCompatActivity { + @Override + protected void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + if (savedInstanceState == null) { + getSupportFragmentManager().beginTransaction() + .add(android.R.id.content, new PersonalPairingPreferenceFragment()) + .commit(); + } + + MainActivityViewModel viewModel = new ViewModelProvider(this, + new ViewModelProvider.AndroidViewModelFactory(getApplication())) + .get(MainActivityViewModel.class); + getLifecycle().addObserver(viewModel); + } + + public static class PersonalPairingPreferenceFragment extends PsiphonPreferenceFragmentCompat + implements SharedPreferences.OnSharedPreferenceChangeListener { + + private CheckBoxPreference enabledPref; + private Preference importPref; + private EditTextPreference compartmentIdPref; + private EditTextPreference aliasPref; + private Preference resetPref; + private Toast currentToast; + private PersonalPairingHelper personalPairingHelper; + + @Override + public void onCreatePreferences(Bundle savedInstanceState, String rootKey) { + super.onCreatePreferences(savedInstanceState, rootKey); + addPreferencesFromResource(R.xml.personal_pairing_preferences); + final PreferenceScreen preferences = getPreferenceScreen(); + + // Initialize preferences + enabledPref = preferences.findPreference(getString(R.string.personalPairingEnabledPreference)); + importPref = preferences.findPreference(getString(R.string.personalPairingImportPreference)); + compartmentIdPref = preferences.findPreference(getString(R.string.personalPairingCompartmentIdPreference)); + aliasPref = preferences.findPreference(getString(R.string.personalPairingAliasPreference)); + resetPref = preferences.findPreference(getString(R.string.personalPairingResetPreference)); + personalPairingHelper = new PersonalPairingHelper(requireContext()); + + // Set initial values from current preferences + final PreferenceGetter preferenceGetter = getPreferenceGetter(); + enabledPref.setChecked(preferenceGetter.getBoolean(getString(R.string.personalPairingEnabledPreference), false)); + compartmentIdPref.setText(preferenceGetter.getString(getString(R.string.personalPairingCompartmentIdPreference), "")); + aliasPref.setText(preferenceGetter.getString(getString(R.string.personalPairingAliasPreference), "")); + + // Set up import button click listener + importPref.setOnPreferenceClickListener(preference -> { + showImportDialog(); + return true; + }); + + // Set up name preference change listener + aliasPref.setOnPreferenceChangeListener((preference, newValue) -> { + if (TextUtils.isEmpty(compartmentIdPref.getText())) { + showToast(R.string.personal_pairing_need_compartment_id, Toast.LENGTH_SHORT); + return false; + } + return true; + }); + + // Set up enabled preference change listener + enabledPref.setOnPreferenceChangeListener((preference, newValue) -> { + boolean newEnabled = (Boolean) newValue; + if (newEnabled && TextUtils.isEmpty(compartmentIdPref.getText())) { + showToast(R.string.personal_pairing_need_compartment_id, Toast.LENGTH_SHORT); + return false; + } + return true; + }); + + // Set up reset click listener + resetPref.setOnPreferenceClickListener(preference -> { + showResetDialog(); + return true; + }); + + updatePersonalPairingPreferencesUI(); + } + + private void showImportDialog() { + View dialogView = LayoutInflater.from(getContext()) + .inflate(R.layout.dialog_import_pairing, null); + EditText urlInput = dialogView.findViewById(R.id.url_input); + + new AlertDialog.Builder(requireContext()) + .setTitle(R.string.personal_pairing_import_dialog_title) + .setView(dialogView) + .setPositiveButton(R.string.import_button, (dialog, which) -> { + String url = urlInput.getText().toString(); + try { + PersonalPairingHelper.PersonalPairingData data = PersonalPairingHelper.extractPersonalPairingData(url); + updatePairingData(data); + // Also enable the feature automatically + enabledPref.setChecked(true); + } catch (IllegalArgumentException e) { + showToast(getPairingImportErrorString( + PersonalPairingHelper.validationErrorFromException(e)), Toast.LENGTH_LONG); + } + }) + .setNegativeButton(android.R.string.cancel, null) + .show(); + } + + private void showResetDialog() { + String compartmentId = compartmentIdPref.getText(); + + // Guard against empty ID + if (compartmentId == null || compartmentId.isEmpty()) { + showToast(R.string.personal_pairing_reset_error, Toast.LENGTH_SHORT); + return; + } + + // Determine how many characters to request + int charsToRequest = Math.min(compartmentId.length(), 4); + String lastChars = compartmentId.substring(compartmentId.length() - charsToRequest); + + View dialogView = LayoutInflater.from(getContext()) + .inflate(R.layout.dialog_reset_pairing, null); + + TextView messageText = dialogView.findViewById(R.id.delete_message); + TextView symbolsText = dialogView.findViewById(R.id.delete_symbols); + + messageText.setText(getString(R.string.personal_pairing_delete_message, charsToRequest)); + symbolsText.setText(lastChars); + + EditText deleteInput = dialogView.findViewById(R.id.delete_input); + deleteInput.setFilters(new InputFilter[] { new InputFilter.LengthFilter(charsToRequest) }); + + AlertDialog dialog = new AlertDialog.Builder(requireContext()) + .setTitle(R.string.personal_pairing_reset_dialog_title) + .setView(dialogView) + .setPositiveButton(R.string.reset_button, null) // Set listener later to prevent auto-dismiss + .setNegativeButton(android.R.string.cancel, null) + .create(); + + // Allow pressing enter to submit + deleteInput.setOnEditorActionListener((v, actionId, event) -> { + if (actionId == EditorInfo.IME_ACTION_DONE) { + Button positiveButton = dialog.getButton(AlertDialog.BUTTON_POSITIVE); + if (positiveButton != null) { + positiveButton.performClick(); + } + return true; + } + return false; + }); + + dialog.setOnShowListener(dialogInterface -> { + Button button = dialog.getButton(AlertDialog.BUTTON_POSITIVE); + button.setOnClickListener(view -> { + String input = deleteInput.getText().toString(); + if (lastChars.equals(input)) { + resetPairingPreferences(); + showToast(R.string.personal_pairing_reset_success, Toast.LENGTH_SHORT); + dialog.dismiss(); + } else { + showToast(R.string.personal_pairing_reset_invalid, Toast.LENGTH_SHORT); + } + }); + }); + + dialog.show(); + } + + private void resetPairingPreferences() { + compartmentIdPref.setText(""); + compartmentIdPref.setSummary(R.string.personal_pairing_compartment_id_summary); + aliasPref.setText(""); + aliasPref.setSummary(R.string.personal_pairing_alias_summary); + enabledPref.setChecked(false); + PersonalPairingHelper.resetPersonalPairingPreferences(getContext()); + } + + private void updatePairingData(PersonalPairingHelper.PersonalPairingData data) { + compartmentIdPref.setText(data.compartmentId); + aliasPref.setText(data.alias); + updatePersonalPairingPreferencesUI(); + persistPersonalPairingState(); + } + + private void persistPersonalPairingState() { + String compartmentId = compartmentIdPref.getText(); + String alias = aliasPref.getText(); + boolean enabled = enabledPref.isChecked(); + + if (compartmentId == null) { + compartmentId = ""; + } + if (alias == null) { + alias = ""; + } + + personalPairingHelper.setPersonalPairingState( + enabled, + new PersonalPairingHelper.PersonalPairingData(compartmentId, alias)); + } + + private void updatePersonalPairingPreferencesUI() { + boolean hasCompartmentId = !TextUtils.isEmpty(compartmentIdPref.getText()); + boolean isEnabled = enabledPref.isChecked(); + + // Update preference states + aliasPref.setEnabled(hasCompartmentId); // Keep editable if compartment ID is set even if the feature is disabled + resetPref.setEnabled(hasCompartmentId); + + compartmentIdPref.setEnabled(isEnabled && hasCompartmentId); + + // If no compartment ID, ensure feature is disabled + if (!hasCompartmentId && isEnabled) { + enabledPref.setChecked(false); + } + + // Update compartment ID and alias summaries if the compartment ID is set, otherwise show the default summary + if (hasCompartmentId) { + String compartmentId = compartmentIdPref.getText(); + compartmentIdPref.setSummary(!TextUtils.isEmpty(compartmentId) ? compartmentId : null); + + String name = aliasPref.getText(); + aliasPref.setSummary(!TextUtils.isEmpty(name) ? name.replace("\n", " ") : null); + } + } + + private void showToast(@StringRes int messageId, int toastLength) { + if (currentToast != null) { + currentToast.cancel(); + } + currentToast = Toast.makeText(getContext(), messageId, toastLength); + currentToast.show(); + } + + @StringRes + private int getPairingImportErrorString(PersonalPairingHelper.ImportValidationError validationError) { + if (validationError == PersonalPairingHelper.ImportValidationError.UNSUPPORTED_VERSION) { + return R.string.personal_pairing_unsupported_version; + } + if (validationError == PersonalPairingHelper.ImportValidationError.INVALID_INPUT_FORMAT) { + return R.string.personal_pairing_invalid_url; + } + return R.string.personal_pairing_invalid_data; + } + + @Override + public void onResume() { + super.onResume(); + getPreferenceScreen().getSharedPreferences() + .registerOnSharedPreferenceChangeListener(this); + } + + @Override + public void onPause() { + super.onPause(); + getPreferenceScreen().getSharedPreferences() + .unregisterOnSharedPreferenceChangeListener(this); + } + + @Override + public void onSharedPreferenceChanged(SharedPreferences sharedPreferences, String key) { + updatePersonalPairingPreferencesUI(); + persistPersonalPairingState(); + } + } +} diff --git a/app/src/main/java/com/psiphon3/psiphonlibrary/TunnelManager.java b/app/src/main/java/com/psiphon3/psiphonlibrary/TunnelManager.java index 36037956f..9c8b8d44a 100644 --- a/app/src/main/java/com/psiphon3/psiphonlibrary/TunnelManager.java +++ b/app/src/main/java/com/psiphon3/psiphonlibrary/TunnelManager.java @@ -130,8 +130,9 @@ enum ServiceToClientMessage { static final String DATA_TUNNEL_STATE_CLIENT_REGION = "clientRegion"; static final String DATA_TUNNEL_STATE_SPONSOR_ID = "sponsorId"; public static final String DATA_TUNNEL_STATE_HOME_PAGES = "homePages"; - public static final String DATA_TUNNEL_STATE_VPN_MODE = "vpnMode"; - public static final String DATA_TUNNEL_STATE_VPN_APPS = "vpnApps"; + public static final String DATA_TUNNEL_STATE_VPN_MODE = "vpnMode"; + public static final String DATA_TUNNEL_STATE_VPN_APPS = "vpnApps"; + public static final String DATA_TUNNEL_STATE_IS_PERSONAL_PAIRING_MODE = "isPersonalPairingMode"; static final String DATA_TRANSFER_STATS_CONNECTED_TIME = "dataTransferStatsConnectedTime"; static final String DATA_TRANSFER_STATS_TOTAL_BYTES_SENT = "dataTransferStatsTotalBytesSent"; static final String DATA_TRANSFER_STATS_TOTAL_BYTES_RECEIVED = "dataTransferStatsTotalBytesReceived"; @@ -153,6 +154,7 @@ static class Config { boolean disableTimeouts = false; String sponsorId = EmbeddedValues.SPONSOR_ID; String deviceLocation = ""; + String personalPairingCompartmentId = ""; } private Config m_tunnelConfig; @@ -172,8 +174,9 @@ public static class State { String clientRegion = ""; String sponsorId = ""; ArrayList homePages = new ArrayList<>(); - VpnAppsUtils.VpnAppsExclusionSetting vpnMode = VpnAppsUtils.VpnAppsExclusionSetting.ALL_APPS; - ArrayList vpnApps = new ArrayList<>(); + VpnAppsUtils.VpnAppsExclusionSetting vpnMode = VpnAppsUtils.VpnAppsExclusionSetting.ALL_APPS; + ArrayList vpnApps = new ArrayList<>(); + public boolean isPersonalPairingMode; boolean isConnected() { return networkConnectionState == TunnelState.ConnectionData.NetworkConnectionState.CONNECTED; @@ -317,7 +320,7 @@ IBinder onBind(Intent intent) { // also updates service notification. private Disposable connectionStatusUpdaterDisposable() { return connectionObservable() - .switchMap(pair -> { + .switchMap(pair -> { TunnelState.ConnectionData.NetworkConnectionState networkConnectionState = pair.first; boolean isRoutingThroughTunnel = pair.second; @@ -342,11 +345,11 @@ private Disposable connectionStatusUpdaterDisposable() { m_isRoutingThroughTunnelPublishRelay.accept(Boolean.TRUE); // Do not emit downstream if we are just started routing. return Observable.empty(); - } - return Observable.just(networkConnectionState); - }) - .distinctUntilChanged() - .doOnNext(networkConnectionState -> { + } + return Observable.just(networkConnectionState); + }) + .distinctUntilChanged() + .doOnNext(networkConnectionState -> { m_tunnelState.networkConnectionState = networkConnectionState; sendClientMessage(ServiceToClientMessage.TUNNEL_CONNECTION_STATE.ordinal(), getTunnelStateBundle()); // Don't update notification to CONNECTING, etc., when a stop was commanded. @@ -536,6 +539,19 @@ private Single getTunnelConfigSingle() { tunnelConfig.disableTimeouts = multiProcessPreferences .getBoolean(getContext().getString(R.string.disableTimeoutsPreference), false); + boolean personalPairingModePreference = multiProcessPreferences.getBoolean( + getContext().getString(R.string.personalPairingEnabledPreference), false); + + // If personal pairing is enabled, get the compartment ID from the preferences + String compartmentId = ""; + if (personalPairingModePreference) { + compartmentId = multiProcessPreferences.getString(getContext().getString(R.string.personalPairingCompartmentIdPreference), ""); + compartmentId = PersonalPairingHelper.toStandardBase64CompartmentId(compartmentId); + if (TextUtils.isEmpty(compartmentId)) { + MyLog.w("TunnelManager::getTunnelConfigSingle: personal pairing is enabled but the compartment ID is empty."); + } + } + tunnelConfig.personalPairingCompartmentId = compartmentId; return tunnelConfig; }); @@ -565,7 +581,7 @@ private Notification createNotification( int defaults = 0; if (networkConnectionState == TunnelState.ConnectionData.NetworkConnectionState.CONNECTED) { - iconID = R.drawable.notification_icon_connected; + iconID = isPersonalPairingMode() ? R.drawable.notification_icon_connected_pp : R.drawable.notification_icon_connected; switch (vpnAppsExclusionSetting) { case INCLUDE_APPS: contentText = getContext().getResources() @@ -587,7 +603,7 @@ private Notification createNotification( contentText = getContext().getString(R.string.waiting_for_network_connectivity); ticker = getContext().getText(R.string.waiting_for_network_connectivity); } else { - iconID = R.drawable.notification_icon_connecting_animation; + iconID = isPersonalPairingMode() ? R.drawable.notification_icon_connecting_animation_pp : R.drawable.notification_icon_connecting_animation; contentText = getContext().getString(R.string.psiphon_service_notification_message_connecting); ticker = getContext().getText(R.string.psiphon_service_notification_message_connecting); } @@ -636,6 +652,10 @@ private Notification createNotification( .build(); } + private boolean isPersonalPairingMode() { + return m_tunnelConfig != null && !TextUtils.isEmpty(m_tunnelConfig.personalPairingCompartmentId); + } + /** * Update the context used to get resources with the passed context * @@ -822,18 +842,18 @@ public void handleMessage(Message msg) { } } - private static void setLocale(TunnelManager manager) { + private static void setLocale(TunnelManager manager) { LocaleManager localeManager = LocaleManager.getInstance(manager.m_parentService); String languageCode = localeManager.getLanguage(); if (localeManager.isSystemLocale(languageCode)) { manager.m_context = localeManager.resetToSystemLocale(manager.m_parentService); } else { manager.m_context = localeManager.setNewLocale(manager.m_parentService, languageCode); - } - manager.updateNotifications(); - // Also update upgrade notifications - UpgradeManager.UpgradeInstaller.updateNotification(manager.getContext()); - } + } + manager.updateNotifications(); + // Also update upgrade notifications + UpgradeManager.UpgradeInstaller.updateNotification(manager.getContext()); + } private Message composeClientMessage(int what, Bundle data) { Message msg = Message.obtain(null, what); @@ -893,10 +913,11 @@ private Bundle getTunnelStateBundle() { data.putString(DATA_TUNNEL_STATE_CLIENT_REGION, m_tunnelState.clientRegion); data.putString(DATA_TUNNEL_STATE_SPONSOR_ID, m_tunnelState.sponsorId); data.putStringArrayList(DATA_TUNNEL_STATE_HOME_PAGES, m_tunnelState.homePages); - data.putSerializable(DATA_TUNNEL_STATE_VPN_MODE, m_tunnelState.vpnMode); - data.putStringArrayList(DATA_TUNNEL_STATE_VPN_APPS, m_tunnelState.vpnApps); - return data; - } + data.putSerializable(DATA_TUNNEL_STATE_VPN_MODE, m_tunnelState.vpnMode); + data.putStringArrayList(DATA_TUNNEL_STATE_VPN_APPS, m_tunnelState.vpnApps); + data.putBoolean(DATA_TUNNEL_STATE_IS_PERSONAL_PAIRING_MODE, isPersonalPairingMode()); + return data; + } private Bundle getDataTransferStatsBundle() { Bundle data = new Bundle(); @@ -944,12 +965,12 @@ private void runTunnel() { final String stdErrRedirectPath = PsiphonCrashService.getStdRedirectPath(m_parentService); NDCrash.nativeInitializeStdErrRedirect(stdErrRedirectPath); - m_isStopping.set(false); - m_networkConnectionStatePublishRelay.accept(TunnelState.ConnectionData.NetworkConnectionState.CONNECTING); - m_isRoutingThroughTunnelPublishRelay.accept(Boolean.FALSE); - - // Notify if an upgrade has already been downloaded and is waiting for install - UpgradeManager.UpgradeInstaller.notifyUpgrade(getContext(), PsiphonTunnel.getDefaultUpgradeDownloadFilePath(getContext())); + m_isStopping.set(false); + m_networkConnectionStatePublishRelay.accept(TunnelState.ConnectionData.NetworkConnectionState.CONNECTING); + m_isRoutingThroughTunnelPublishRelay.accept(Boolean.FALSE); + + // Notify if an upgrade has already been downloaded and is waiting for install + UpgradeManager.UpgradeInstaller.notifyUpgrade(getContext(), PsiphonTunnel.getDefaultUpgradeDownloadFilePath(getContext())); MyLog.i(R.string.starting_tunnel, MyLog.Sensitivity.NOT_SENSITIVE); @@ -1251,19 +1272,19 @@ public static String buildTunnelCoreConfig( try { - json.put("ClientVersion", EmbeddedValues.CLIENT_VERSION); - - if (UpgradeChecker.upgradeCheckNeeded(context)) { - - json.put("UpgradeDownloadURLs", new JSONArray(EmbeddedValues.UPGRADE_URLS_JSON)); - - json.put("UpgradeDownloadClientVersionHeader", "x-amz-meta-psiphon-client-version"); - - json.put("EnableUpgradeDownload", true); - } - - json.put("MigrateUpgradeDownloadFilename", - new UpgradeManager.OldDownloadedUpgradeFile(context).getFullPath()); + json.put("ClientVersion", EmbeddedValues.CLIENT_VERSION); + + if (UpgradeChecker.upgradeCheckNeeded(context)) { + + json.put("UpgradeDownloadURLs", new JSONArray(EmbeddedValues.UPGRADE_URLS_JSON)); + + json.put("UpgradeDownloadClientVersionHeader", "x-amz-meta-psiphon-client-version"); + + json.put("EnableUpgradeDownload", true); + } + + json.put("MigrateUpgradeDownloadFilename", + new UpgradeManager.OldDownloadedUpgradeFile(context).getFullPath()); json.put("PropagationChannelId", EmbeddedValues.PROPAGATION_CHANNEL_ID); @@ -1369,6 +1390,11 @@ public static String buildTunnelCoreConfig( json.put("EmitBytesTransferred", true); + // Set the personal pairing config if config has a non-empty personal pairing compartment ID + if (!TextUtils.isEmpty(tunnelConfig.personalPairingCompartmentId)) { + json.put("InproxyClientPersonalCompartmentID", tunnelConfig.personalPairingCompartmentId); + } + return json.toString(); } catch (JSONException e) { return null; @@ -1378,15 +1404,15 @@ public static String buildTunnelCoreConfig( // This observable emits a pair consisting of the latest NetworkConnectionState state and a // Boolean representing whether we are routing the traffic via tunnel. // Emits a new pair every time when either of the sources emits a new value. - private Observable> connectionObservable() { - return Observable.combineLatest(m_networkConnectionStatePublishRelay, - m_isRoutingThroughTunnelPublishRelay, - ((BiFunction>) Pair::new)) - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .distinctUntilChanged(); - } + private Observable> connectionObservable() { + return Observable.combineLatest(m_networkConnectionStatePublishRelay, + m_isRoutingThroughTunnelPublishRelay, + ((BiFunction>) Pair::new)) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .distinctUntilChanged(); + } /** * Configure tunnel with appropriate client platform affixes (i.e., the main Psiphon app @@ -1660,27 +1686,27 @@ public void run() { } @Override - public void onClientRegion(final String region) { - m_Handler.post(new Runnable() { - @Override - public void run() { - m_tunnelState.clientRegion = region; - } - }); - } - - @Override - public void onClientUpgradeDownloaded(String filename) { - m_Handler.post(new Runnable() { - @Override - public void run() { - UpgradeManager.UpgradeInstaller.notifyUpgrade(getContext(), filename); - } - }); - } - - @Override - public void onUntunneledAddress(final String address) { + public void onClientRegion(final String region) { + m_Handler.post(new Runnable() { + @Override + public void run() { + m_tunnelState.clientRegion = region; + } + }); + } + + @Override + public void onClientUpgradeDownloaded(String filename) { + m_Handler.post(new Runnable() { + @Override + public void run() { + UpgradeManager.UpgradeInstaller.notifyUpgrade(getContext(), filename); + } + }); + } + + @Override + public void onUntunneledAddress(final String address) { m_Handler.post(new Runnable() { @Override public void run() { diff --git a/app/src/main/java/com/psiphon3/psiphonlibrary/TunnelServiceInteractor.java b/app/src/main/java/com/psiphon3/psiphonlibrary/TunnelServiceInteractor.java index 4053f936e..d64a01e1b 100644 --- a/app/src/main/java/com/psiphon3/psiphonlibrary/TunnelServiceInteractor.java +++ b/app/src/main/java/com/psiphon3/psiphonlibrary/TunnelServiceInteractor.java @@ -298,6 +298,7 @@ private static TunnelManager.State getTunnelStateFromBundle(Bundle data) { if (vpnApps != null) { tunnelState.vpnApps = vpnApps; } + tunnelState.isPersonalPairingMode = data.getBoolean(TunnelManager.DATA_TUNNEL_STATE_IS_PERSONAL_PAIRING_MODE); return tunnelState; } @@ -352,6 +353,7 @@ public void handleMessage(Message msg) { .setHomePages(state.homePages) .setVpnMode(state.vpnMode) .setVpnApps(state.vpnApps) + .setPersonalPairingEnabled(state.isPersonalPairingMode) .build(); tunnelState = TunnelState.running(connectionData); } else { diff --git a/app/src/main/res/drawable-hdpi/notification_icon_connected_pp.png b/app/src/main/res/drawable-hdpi/notification_icon_connected_pp.png new file mode 100644 index 000000000..2a9b05d48 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/notification_icon_connected_pp.png differ diff --git a/app/src/main/res/drawable-hdpi/notification_icon_connecting_pp_01.png b/app/src/main/res/drawable-hdpi/notification_icon_connecting_pp_01.png new file mode 100644 index 000000000..cb266a8db Binary files /dev/null and b/app/src/main/res/drawable-hdpi/notification_icon_connecting_pp_01.png differ diff --git a/app/src/main/res/drawable-hdpi/notification_icon_connecting_pp_02.png b/app/src/main/res/drawable-hdpi/notification_icon_connecting_pp_02.png new file mode 100644 index 000000000..468c5d9e6 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/notification_icon_connecting_pp_02.png differ diff --git a/app/src/main/res/drawable-hdpi/notification_icon_connecting_pp_03.png b/app/src/main/res/drawable-hdpi/notification_icon_connecting_pp_03.png new file mode 100644 index 000000000..1334ca516 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/notification_icon_connecting_pp_03.png differ diff --git a/app/src/main/res/drawable-hdpi/notification_icon_connecting_pp_04.png b/app/src/main/res/drawable-hdpi/notification_icon_connecting_pp_04.png new file mode 100644 index 000000000..06293783f Binary files /dev/null and b/app/src/main/res/drawable-hdpi/notification_icon_connecting_pp_04.png differ diff --git a/app/src/main/res/drawable-hdpi/notification_icon_connecting_pp_05.png b/app/src/main/res/drawable-hdpi/notification_icon_connecting_pp_05.png new file mode 100644 index 000000000..106a79226 Binary files /dev/null and b/app/src/main/res/drawable-hdpi/notification_icon_connecting_pp_05.png differ diff --git a/app/src/main/res/drawable-hdpi/notification_icon_connecting_pp_06.png b/app/src/main/res/drawable-hdpi/notification_icon_connecting_pp_06.png new file mode 100644 index 000000000..aee1a443a Binary files /dev/null and b/app/src/main/res/drawable-hdpi/notification_icon_connecting_pp_06.png differ diff --git a/app/src/main/res/drawable-hdpi/status_icon_connected.png b/app/src/main/res/drawable-hdpi/status_icon_connected.png deleted file mode 100644 index 406eb6fd3..000000000 Binary files a/app/src/main/res/drawable-hdpi/status_icon_connected.png and /dev/null differ diff --git a/app/src/main/res/drawable-hdpi/status_icon_connecting.png b/app/src/main/res/drawable-hdpi/status_icon_connecting.png deleted file mode 100644 index 06c3cec4d..000000000 Binary files a/app/src/main/res/drawable-hdpi/status_icon_connecting.png and /dev/null differ diff --git a/app/src/main/res/drawable-hdpi/status_icon_disconnected.png b/app/src/main/res/drawable-hdpi/status_icon_disconnected.png deleted file mode 100644 index 5bfb44ec7..000000000 Binary files a/app/src/main/res/drawable-hdpi/status_icon_disconnected.png and /dev/null differ diff --git a/app/src/main/res/drawable-mdpi/notification_icon_connected_pp.png b/app/src/main/res/drawable-mdpi/notification_icon_connected_pp.png new file mode 100644 index 000000000..d81ad3f62 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/notification_icon_connected_pp.png differ diff --git a/app/src/main/res/drawable-mdpi/notification_icon_connecting_pp_01.png b/app/src/main/res/drawable-mdpi/notification_icon_connecting_pp_01.png new file mode 100644 index 000000000..750c924e2 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/notification_icon_connecting_pp_01.png differ diff --git a/app/src/main/res/drawable-mdpi/notification_icon_connecting_pp_02.png b/app/src/main/res/drawable-mdpi/notification_icon_connecting_pp_02.png new file mode 100644 index 000000000..2ab44faed Binary files /dev/null and b/app/src/main/res/drawable-mdpi/notification_icon_connecting_pp_02.png differ diff --git a/app/src/main/res/drawable-mdpi/notification_icon_connecting_pp_03.png b/app/src/main/res/drawable-mdpi/notification_icon_connecting_pp_03.png new file mode 100644 index 000000000..46966f9c5 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/notification_icon_connecting_pp_03.png differ diff --git a/app/src/main/res/drawable-mdpi/notification_icon_connecting_pp_04.png b/app/src/main/res/drawable-mdpi/notification_icon_connecting_pp_04.png new file mode 100644 index 000000000..46f8639c3 Binary files /dev/null and b/app/src/main/res/drawable-mdpi/notification_icon_connecting_pp_04.png differ diff --git a/app/src/main/res/drawable-mdpi/notification_icon_connecting_pp_05.png b/app/src/main/res/drawable-mdpi/notification_icon_connecting_pp_05.png new file mode 100644 index 000000000..d03bfca1b Binary files /dev/null and b/app/src/main/res/drawable-mdpi/notification_icon_connecting_pp_05.png differ diff --git a/app/src/main/res/drawable-mdpi/notification_icon_connecting_pp_06.png b/app/src/main/res/drawable-mdpi/notification_icon_connecting_pp_06.png new file mode 100644 index 000000000..a645e642a Binary files /dev/null and b/app/src/main/res/drawable-mdpi/notification_icon_connecting_pp_06.png differ diff --git a/app/src/main/res/drawable-mdpi/status_icon_connected.png b/app/src/main/res/drawable-mdpi/status_icon_connected.png deleted file mode 100644 index 558982e8b..000000000 Binary files a/app/src/main/res/drawable-mdpi/status_icon_connected.png and /dev/null differ diff --git a/app/src/main/res/drawable-mdpi/status_icon_connecting.png b/app/src/main/res/drawable-mdpi/status_icon_connecting.png deleted file mode 100644 index 6c7904bf6..000000000 Binary files a/app/src/main/res/drawable-mdpi/status_icon_connecting.png and /dev/null differ diff --git a/app/src/main/res/drawable-mdpi/status_icon_disconnected.png b/app/src/main/res/drawable-mdpi/status_icon_disconnected.png deleted file mode 100644 index 6400d7995..000000000 Binary files a/app/src/main/res/drawable-mdpi/status_icon_disconnected.png and /dev/null differ diff --git a/app/src/main/res/drawable-xhdpi/notification_icon_connected_pp.png b/app/src/main/res/drawable-xhdpi/notification_icon_connected_pp.png new file mode 100644 index 000000000..0085eb837 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/notification_icon_connected_pp.png differ diff --git a/app/src/main/res/drawable-xhdpi/notification_icon_connecting_pp_01.png b/app/src/main/res/drawable-xhdpi/notification_icon_connecting_pp_01.png new file mode 100644 index 000000000..1f2991481 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/notification_icon_connecting_pp_01.png differ diff --git a/app/src/main/res/drawable-xhdpi/notification_icon_connecting_pp_02.png b/app/src/main/res/drawable-xhdpi/notification_icon_connecting_pp_02.png new file mode 100644 index 000000000..0585325e8 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/notification_icon_connecting_pp_02.png differ diff --git a/app/src/main/res/drawable-xhdpi/notification_icon_connecting_pp_03.png b/app/src/main/res/drawable-xhdpi/notification_icon_connecting_pp_03.png new file mode 100644 index 000000000..d5ddfdb28 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/notification_icon_connecting_pp_03.png differ diff --git a/app/src/main/res/drawable-xhdpi/notification_icon_connecting_pp_04.png b/app/src/main/res/drawable-xhdpi/notification_icon_connecting_pp_04.png new file mode 100644 index 000000000..57aa8b084 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/notification_icon_connecting_pp_04.png differ diff --git a/app/src/main/res/drawable-xhdpi/notification_icon_connecting_pp_05.png b/app/src/main/res/drawable-xhdpi/notification_icon_connecting_pp_05.png new file mode 100644 index 000000000..8719e3e5f Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/notification_icon_connecting_pp_05.png differ diff --git a/app/src/main/res/drawable-xhdpi/notification_icon_connecting_pp_06.png b/app/src/main/res/drawable-xhdpi/notification_icon_connecting_pp_06.png new file mode 100644 index 000000000..dcc4a7137 Binary files /dev/null and b/app/src/main/res/drawable-xhdpi/notification_icon_connecting_pp_06.png differ diff --git a/app/src/main/res/drawable-xhdpi/status_icon_connected.png b/app/src/main/res/drawable-xhdpi/status_icon_connected.png deleted file mode 100644 index f33a5e0dd..000000000 Binary files a/app/src/main/res/drawable-xhdpi/status_icon_connected.png and /dev/null differ diff --git a/app/src/main/res/drawable-xhdpi/status_icon_connecting.png b/app/src/main/res/drawable-xhdpi/status_icon_connecting.png deleted file mode 100644 index d974f3582..000000000 Binary files a/app/src/main/res/drawable-xhdpi/status_icon_connecting.png and /dev/null differ diff --git a/app/src/main/res/drawable-xhdpi/status_icon_disconnected.png b/app/src/main/res/drawable-xhdpi/status_icon_disconnected.png deleted file mode 100644 index 0ff4839fa..000000000 Binary files a/app/src/main/res/drawable-xhdpi/status_icon_disconnected.png and /dev/null differ diff --git a/app/src/main/res/drawable-xxhdpi/notification_icon_connected_pp.png b/app/src/main/res/drawable-xxhdpi/notification_icon_connected_pp.png new file mode 100644 index 000000000..34c0e1f54 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/notification_icon_connected_pp.png differ diff --git a/app/src/main/res/drawable-xxhdpi/notification_icon_connecting_pp_01.png b/app/src/main/res/drawable-xxhdpi/notification_icon_connecting_pp_01.png new file mode 100644 index 000000000..1f71f4a17 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/notification_icon_connecting_pp_01.png differ diff --git a/app/src/main/res/drawable-xxhdpi/notification_icon_connecting_pp_02.png b/app/src/main/res/drawable-xxhdpi/notification_icon_connecting_pp_02.png new file mode 100644 index 000000000..5e47f8a8d Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/notification_icon_connecting_pp_02.png differ diff --git a/app/src/main/res/drawable-xxhdpi/notification_icon_connecting_pp_03.png b/app/src/main/res/drawable-xxhdpi/notification_icon_connecting_pp_03.png new file mode 100644 index 000000000..8a1287698 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/notification_icon_connecting_pp_03.png differ diff --git a/app/src/main/res/drawable-xxhdpi/notification_icon_connecting_pp_04.png b/app/src/main/res/drawable-xxhdpi/notification_icon_connecting_pp_04.png new file mode 100644 index 000000000..ee6f9bcee Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/notification_icon_connecting_pp_04.png differ diff --git a/app/src/main/res/drawable-xxhdpi/notification_icon_connecting_pp_05.png b/app/src/main/res/drawable-xxhdpi/notification_icon_connecting_pp_05.png new file mode 100644 index 000000000..45d2a06dc Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/notification_icon_connecting_pp_05.png differ diff --git a/app/src/main/res/drawable-xxhdpi/notification_icon_connecting_pp_06.png b/app/src/main/res/drawable-xxhdpi/notification_icon_connecting_pp_06.png new file mode 100644 index 000000000..71e0f2805 Binary files /dev/null and b/app/src/main/res/drawable-xxhdpi/notification_icon_connecting_pp_06.png differ diff --git a/app/src/main/res/drawable-xxhdpi/status_icon_connected.png b/app/src/main/res/drawable-xxhdpi/status_icon_connected.png deleted file mode 100644 index a23e3c034..000000000 Binary files a/app/src/main/res/drawable-xxhdpi/status_icon_connected.png and /dev/null differ diff --git a/app/src/main/res/drawable-xxhdpi/status_icon_connecting.png b/app/src/main/res/drawable-xxhdpi/status_icon_connecting.png deleted file mode 100644 index 007edb0de..000000000 Binary files a/app/src/main/res/drawable-xxhdpi/status_icon_connecting.png and /dev/null differ diff --git a/app/src/main/res/drawable-xxhdpi/status_icon_disconnected.png b/app/src/main/res/drawable-xxhdpi/status_icon_disconnected.png deleted file mode 100644 index 501f38dae..000000000 Binary files a/app/src/main/res/drawable-xxhdpi/status_icon_disconnected.png and /dev/null differ diff --git a/app/src/main/res/drawable-xxxhdpi/notification_icon_connected_pp.png b/app/src/main/res/drawable-xxxhdpi/notification_icon_connected_pp.png new file mode 100644 index 000000000..33c51c036 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/notification_icon_connected_pp.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/notification_icon_connecting_pp_01.png b/app/src/main/res/drawable-xxxhdpi/notification_icon_connecting_pp_01.png new file mode 100644 index 000000000..eccad30f9 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/notification_icon_connecting_pp_01.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/notification_icon_connecting_pp_02.png b/app/src/main/res/drawable-xxxhdpi/notification_icon_connecting_pp_02.png new file mode 100644 index 000000000..6289ffb83 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/notification_icon_connecting_pp_02.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/notification_icon_connecting_pp_03.png b/app/src/main/res/drawable-xxxhdpi/notification_icon_connecting_pp_03.png new file mode 100644 index 000000000..292fc34e8 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/notification_icon_connecting_pp_03.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/notification_icon_connecting_pp_04.png b/app/src/main/res/drawable-xxxhdpi/notification_icon_connecting_pp_04.png new file mode 100644 index 000000000..c7fad8874 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/notification_icon_connecting_pp_04.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/notification_icon_connecting_pp_05.png b/app/src/main/res/drawable-xxxhdpi/notification_icon_connecting_pp_05.png new file mode 100644 index 000000000..b3f5394c3 Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/notification_icon_connecting_pp_05.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/notification_icon_connecting_pp_06.png b/app/src/main/res/drawable-xxxhdpi/notification_icon_connecting_pp_06.png new file mode 100644 index 000000000..39f78133c Binary files /dev/null and b/app/src/main/res/drawable-xxxhdpi/notification_icon_connecting_pp_06.png differ diff --git a/app/src/main/res/drawable-xxxhdpi/status_icon_connected.png b/app/src/main/res/drawable-xxxhdpi/status_icon_connected.png deleted file mode 100644 index 47e928102..000000000 Binary files a/app/src/main/res/drawable-xxxhdpi/status_icon_connected.png and /dev/null differ diff --git a/app/src/main/res/drawable-xxxhdpi/status_icon_connecting.png b/app/src/main/res/drawable-xxxhdpi/status_icon_connecting.png deleted file mode 100644 index 2e5bc4303..000000000 Binary files a/app/src/main/res/drawable-xxxhdpi/status_icon_connecting.png and /dev/null differ diff --git a/app/src/main/res/drawable-xxxhdpi/status_icon_disconnected.png b/app/src/main/res/drawable-xxxhdpi/status_icon_disconnected.png deleted file mode 100644 index d2df3daeb..000000000 Binary files a/app/src/main/res/drawable-xxxhdpi/status_icon_disconnected.png and /dev/null differ diff --git a/app/src/main/res/drawable/notification_icon_connecting_animation_pp.xml b/app/src/main/res/drawable/notification_icon_connecting_animation_pp.xml new file mode 100644 index 000000000..1350c56eb --- /dev/null +++ b/app/src/main/res/drawable/notification_icon_connecting_animation_pp.xml @@ -0,0 +1,12 @@ + + + + + + + + + + diff --git a/app/src/main/res/drawable/p2p_24px.xml b/app/src/main/res/drawable/p2p_24px.xml new file mode 100644 index 000000000..47fa2da86 --- /dev/null +++ b/app/src/main/res/drawable/p2p_24px.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/status_icon_connected.xml b/app/src/main/res/drawable/status_icon_connected.xml new file mode 100644 index 000000000..2b68223a3 --- /dev/null +++ b/app/src/main/res/drawable/status_icon_connected.xml @@ -0,0 +1,21 @@ + + + + + + + + + + diff --git a/app/src/main/res/drawable/status_icon_connected_pp.xml b/app/src/main/res/drawable/status_icon_connected_pp.xml new file mode 100644 index 000000000..c8c995709 --- /dev/null +++ b/app/src/main/res/drawable/status_icon_connected_pp.xml @@ -0,0 +1,21 @@ + + + + + + + + + + diff --git a/app/src/main/res/drawable/status_icon_connecting.xml b/app/src/main/res/drawable/status_icon_connecting.xml new file mode 100644 index 000000000..788f53f44 --- /dev/null +++ b/app/src/main/res/drawable/status_icon_connecting.xml @@ -0,0 +1,21 @@ + + + + + + + + + + diff --git a/app/src/main/res/drawable/status_icon_connecting_pp.xml b/app/src/main/res/drawable/status_icon_connecting_pp.xml new file mode 100644 index 000000000..9f9c21d71 --- /dev/null +++ b/app/src/main/res/drawable/status_icon_connecting_pp.xml @@ -0,0 +1,21 @@ + + + + + + + + + + diff --git a/app/src/main/res/drawable/status_icon_disconnected.xml b/app/src/main/res/drawable/status_icon_disconnected.xml new file mode 100644 index 000000000..49f05c6a8 --- /dev/null +++ b/app/src/main/res/drawable/status_icon_disconnected.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/layout/dialog_import_pairing.xml b/app/src/main/res/layout/dialog_import_pairing.xml new file mode 100644 index 000000000..d8aa1f850 --- /dev/null +++ b/app/src/main/res/layout/dialog_import_pairing.xml @@ -0,0 +1,21 @@ + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/dialog_pairing_enable.xml b/app/src/main/res/layout/dialog_pairing_enable.xml new file mode 100644 index 000000000..1a6760173 --- /dev/null +++ b/app/src/main/res/layout/dialog_pairing_enable.xml @@ -0,0 +1,24 @@ + + + + + + + + + + diff --git a/app/src/main/res/layout/dialog_pairing_update.xml b/app/src/main/res/layout/dialog_pairing_update.xml new file mode 100644 index 000000000..80b21fe93 --- /dev/null +++ b/app/src/main/res/layout/dialog_pairing_update.xml @@ -0,0 +1,37 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/dialog_reset_pairing.xml b/app/src/main/res/layout/dialog_reset_pairing.xml new file mode 100644 index 000000000..44ee06912 --- /dev/null +++ b/app/src/main/res/layout/dialog_reset_pairing.xml @@ -0,0 +1,38 @@ + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/main_activity.xml b/app/src/main/res/layout/main_activity.xml index 62f9701b0..1a15a4ff2 100644 --- a/app/src/main/res/layout/main_activity.xml +++ b/app/src/main/res/layout/main_activity.xml @@ -84,25 +84,65 @@ android:layout_width="match_parent" android:layout_height="wrap_content"> + + + + + + + +