From 99da50d37c3f227026f2ad795f877e08b7ba65ea Mon Sep 17 00:00:00 2001 From: Torsten Grote Date: Mon, 17 May 2021 11:26:10 -0300 Subject: [PATCH] Port code from Offline hotspot test app --- briar-android/build.gradle | 1 + briar-android/src/main/AndroidManifest.xml | 1 + briar-android/src/main/assets/hotspot.html | 103 ++++++ .../briar/android/activity/BaseActivity.java | 9 +- .../add/nearby/AddNearbyContactViewModel.java | 1 + .../android/hotspot/AbstractTabsFragment.java | 13 +- .../android/hotspot/ConditionManager.java | 168 ++++++++++ .../android/hotspot/HotspotActivity.java | 22 +- .../android/hotspot/HotspotFragment.java | 14 +- .../android/hotspot/HotspotIntroFragment.java | 95 ++++-- .../briar/android/hotspot/HotspotManager.java | 299 ++++++++++++++++++ .../briar/android/hotspot/HotspotState.java | 66 ++++ .../android/hotspot/HotspotViewModel.java | 98 +++++- .../hotspot/ManualHotspotFragment.java | 19 +- .../android/hotspot/QrHotspotFragment.java | 14 +- .../briar/android/hotspot/WebServer.java | 130 ++++++++ .../android/hotspot/WebServerManager.java | 114 +++++++ .../add/nearby => util}/QrCodeUtils.java | 6 +- .../briar/android/util/UiUtils.java | 13 + .../main/res/layout/fragment_hotspot_qr.xml | 25 +- briar-android/src/main/res/values/strings.xml | 24 ++ briar-android/witness.gradle | 1 + 22 files changed, 1156 insertions(+), 80 deletions(-) create mode 100644 briar-android/src/main/assets/hotspot.html create mode 100644 briar-android/src/main/java/org/briarproject/briar/android/hotspot/ConditionManager.java create mode 100644 briar-android/src/main/java/org/briarproject/briar/android/hotspot/HotspotManager.java create mode 100644 briar-android/src/main/java/org/briarproject/briar/android/hotspot/HotspotState.java create mode 100644 briar-android/src/main/java/org/briarproject/briar/android/hotspot/WebServer.java create mode 100644 briar-android/src/main/java/org/briarproject/briar/android/hotspot/WebServerManager.java rename briar-android/src/main/java/org/briarproject/briar/android/{contact/add/nearby => util}/QrCodeUtils.java (91%) diff --git a/briar-android/build.gradle b/briar-android/build.gradle index bf90f1b36..240ffd005 100644 --- a/briar-android/build.gradle +++ b/briar-android/build.gradle @@ -121,6 +121,7 @@ dependencies { exclude group: 'com.android.support' exclude module: 'disklrucache' // when there's no disk cache, we can't accidentally use it } + implementation 'org.nanohttpd:nanohttpd:2.3.1' annotationProcessor 'com.google.dagger:dagger-compiler:2.24' annotationProcessor "com.github.bumptech.glide:compiler:$glideVersion" diff --git a/briar-android/src/main/AndroidManifest.xml b/briar-android/src/main/AndroidManifest.xml index e610211dd..4de40296e 100644 --- a/briar-android/src/main/AndroidManifest.xml +++ b/briar-android/src/main/AndroidManifest.xml @@ -18,6 +18,7 @@ + diff --git a/briar-android/src/main/assets/hotspot.html b/briar-android/src/main/assets/hotspot.html new file mode 100644 index 000000000..057e0ef42 --- /dev/null +++ b/briar-android/src/main/assets/hotspot.html @@ -0,0 +1,103 @@ + + + + + + + + +
+ + + + + + +

Download Briar 1.2.20

+ + Someone nearby shared Briar with you. + + + + Download Briar + + + After the download is complete, open the downloaded file and install it. +
+ +
+

Troubleshooting

+
    +
  1. If you can't download the app, try it with a different web + browser app. +
  2. +
  3. Ensure that your browser is allowed to download apps directly by + giving it the permission or enabling the installation of apps from "Unknown Sources" in + system settings. +
  4. +
+
+ + + diff --git a/briar-android/src/main/java/org/briarproject/briar/android/activity/BaseActivity.java b/briar-android/src/main/java/org/briarproject/briar/android/activity/BaseActivity.java index da993afed..0c4761ada 100644 --- a/briar-android/src/main/java/org/briarproject/briar/android/activity/BaseActivity.java +++ b/briar-android/src/main/java/org/briarproject/briar/android/activity/BaseActivity.java @@ -46,6 +46,7 @@ import static java.util.logging.Level.INFO; import static java.util.logging.Logger.getLogger; import static org.briarproject.briar.android.TestingConstants.PREVENT_SCREENSHOTS; import static org.briarproject.briar.android.util.UiUtils.hideSoftKeyboard; +import static org.briarproject.briar.android.util.UiUtils.showFragment; /** * Warning: Some activities don't extend {@link BaseActivity}. @@ -177,13 +178,7 @@ public abstract class BaseActivity extends AppCompatActivity public void showNextFragment(BaseFragment f) { if (!getLifecycle().getCurrentState().isAtLeast(STARTED)) return; - getSupportFragmentManager().beginTransaction() - .setCustomAnimations(R.anim.step_next_in, - R.anim.step_previous_out, R.anim.step_previous_in, - R.anim.step_next_out) - .replace(R.id.fragmentContainer, f, f.getUniqueTag()) - .addToBackStack(f.getUniqueTag()) - .commit(); + showFragment(getSupportFragmentManager(), f, f.getUniqueTag()); } protected boolean isFragmentAdded(String fragmentTag) { diff --git a/briar-android/src/main/java/org/briarproject/briar/android/contact/add/nearby/AddNearbyContactViewModel.java b/briar-android/src/main/java/org/briarproject/briar/android/contact/add/nearby/AddNearbyContactViewModel.java index d6d225100..0cf7c82c5 100644 --- a/briar-android/src/main/java/org/briarproject/briar/android/contact/add/nearby/AddNearbyContactViewModel.java +++ b/briar-android/src/main/java/org/briarproject/briar/android/contact/add/nearby/AddNearbyContactViewModel.java @@ -53,6 +53,7 @@ import org.briarproject.briar.android.contact.add.nearby.AddContactState.Contact import org.briarproject.briar.android.contact.add.nearby.AddContactState.KeyAgreementListening; import org.briarproject.briar.android.contact.add.nearby.AddContactState.KeyAgreementStarted; import org.briarproject.briar.android.contact.add.nearby.AddContactState.KeyAgreementWaiting; +import org.briarproject.briar.android.util.QrCodeUtils; import org.briarproject.briar.android.viewmodel.LiveEvent; import org.briarproject.briar.android.viewmodel.MutableLiveEvent; diff --git a/briar-android/src/main/java/org/briarproject/briar/android/hotspot/AbstractTabsFragment.java b/briar-android/src/main/java/org/briarproject/briar/android/hotspot/AbstractTabsFragment.java index 17027c30f..a3b940734 100644 --- a/briar-android/src/main/java/org/briarproject/briar/android/hotspot/AbstractTabsFragment.java +++ b/briar-android/src/main/java/org/briarproject/briar/android/hotspot/AbstractTabsFragment.java @@ -28,6 +28,7 @@ import androidx.viewpager2.widget.ViewPager2; import static androidx.core.app.ActivityCompat.finishAfterTransition; import static org.briarproject.briar.android.AppModule.getAndroidComponent; +import static org.briarproject.briar.android.util.UiUtils.showFragment; @MethodsNotNullByDefault @ParametersNotNullByDefault @@ -95,15 +96,9 @@ public abstract class AbstractTabsFragment extends Fragment { @Override public boolean onOptionsItemSelected(MenuItem item) { if (item.getItemId() == R.id.action_help) { - getParentFragmentManager().beginTransaction() - .setCustomAnimations(R.anim.step_next_in, - R.anim.step_previous_out, - R.anim.step_previous_in, - R.anim.step_next_out) - .replace(R.id.fragmentContainer, new HotspotHelpFragment(), - HotspotHelpFragment.TAG) - .addToBackStack(HotspotHelpFragment.TAG) - .commit(); + Fragment f = new HotspotHelpFragment(); + String tag = HotspotHelpFragment.TAG; + showFragment(getParentFragmentManager(), f, tag); return true; } return super.onOptionsItemSelected(item); diff --git a/briar-android/src/main/java/org/briarproject/briar/android/hotspot/ConditionManager.java b/briar-android/src/main/java/org/briarproject/briar/android/hotspot/ConditionManager.java new file mode 100644 index 000000000..10c5f78e2 --- /dev/null +++ b/briar-android/src/main/java/org/briarproject/briar/android/hotspot/ConditionManager.java @@ -0,0 +1,168 @@ +package org.briarproject.briar.android.hotspot; + +import android.content.DialogInterface.OnClickListener; +import android.content.Intent; +import android.net.wifi.WifiManager; +import android.provider.Settings; + +import org.briarproject.briar.R; + +import androidx.activity.result.ActivityResult; +import androidx.activity.result.ActivityResultLauncher; +import androidx.annotation.StringRes; +import androidx.appcompat.app.AlertDialog; +import androidx.fragment.app.FragmentActivity; + +import static android.Manifest.permission.ACCESS_FINE_LOCATION; +import static android.content.Context.WIFI_SERVICE; +import static android.os.Build.VERSION.SDK_INT; +import static androidx.core.app.ActivityCompat.shouldShowRequestPermissionRationale; +import static org.briarproject.briar.android.util.UiUtils.getGoToSettingsListener; + +/** + * This class ensures that the conditions to open a hotspot are fulfilled. + *

+ * Be sure to call {@link #onRequestPermissionResult(Boolean)} and + * {@link #onRequestWifiEnabledResult()} when you get the + * {@link ActivityResult}. + *

+ * As soon as {@link #checkAndRequestConditions()} returns true, + * all conditions are fulfilled. + */ +class ConditionManager { + + private enum Permission { + UNKNOWN, GRANTED, SHOW_RATIONALE, PERMANENTLY_DENIED + } + + private Permission locationPermission = Permission.UNKNOWN; + private Permission wifiSetting = Permission.SHOW_RATIONALE; + + private final FragmentActivity ctx; + private final WifiManager wifiManager; + private final ActivityResultLauncher locationRequest; + private final ActivityResultLauncher wifiRequest; + + ConditionManager(FragmentActivity ctx, + ActivityResultLauncher locationRequest, + ActivityResultLauncher wifiRequest) { + this.ctx = ctx; + this.wifiManager = (WifiManager) ctx.getApplicationContext() + .getSystemService(WIFI_SERVICE); + this.locationRequest = locationRequest; + this.wifiRequest = wifiRequest; + } + + /** + * Call this to reset state when UI starts, + * because state might have changed. + */ + void resetPermissions() { + locationPermission = Permission.UNKNOWN; + wifiSetting = Permission.SHOW_RATIONALE; + } + + /** + * This makes a request for location permission. + * If {@link #checkAndRequestConditions()} returns true, you can continue. + */ + void startConditionChecks() { + locationRequest.launch(ACCESS_FINE_LOCATION); + } + + /** + * @return true if conditions are fulfilled and flow can continue. + */ + boolean checkAndRequestConditions() { + if (areEssentialPermissionsGranted()) return true; + + // If an essential permission has been permanently denied, ask the + // user to change the setting + if (locationPermission == Permission.PERMANENTLY_DENIED) { + showDenialDialog(R.string.permission_location_title, + R.string.permission_hotspot_location_denied_body, + getGoToSettingsListener(ctx)); + return false; + } + if (wifiSetting == Permission.PERMANENTLY_DENIED) { + showDenialDialog(R.string.wifi_settings_title, + R.string.wifi_settings_request_denied_body, + (d, w) -> requestEnableWiFi()); + return false; + } + + // Should we show the rationale for location permission or Wi-Fi? + if (locationPermission == Permission.SHOW_RATIONALE) { + showRationale(R.string.permission_location_title, + R.string.permission_hotspot_location_request_body, + this::requestPermissions); + } else if (wifiSetting == Permission.SHOW_RATIONALE) { + showRationale(R.string.wifi_settings_title, + R.string.wifi_settings_request_enable_body, + this::requestEnableWiFi); + } + return false; + } + + void onRequestPermissionResult(Boolean granted) { + if (granted != null && granted) { + locationPermission = Permission.GRANTED; + } else if (shouldShowRequestPermissionRationale(ctx, + ACCESS_FINE_LOCATION)) { + locationPermission = Permission.SHOW_RATIONALE; + } else { + locationPermission = Permission.PERMANENTLY_DENIED; + } + } + + void onRequestWifiEnabledResult() { + wifiSetting = wifiManager.isWifiEnabled() ? Permission.GRANTED : + Permission.PERMANENTLY_DENIED; + } + + private boolean areEssentialPermissionsGranted() { + if (SDK_INT < 29) { + if (!wifiManager.isWifiEnabled()) { + //noinspection deprecation + return wifiManager.setWifiEnabled(true); + } + return true; + } else { + return locationPermission == Permission.GRANTED + && wifiManager.isWifiEnabled(); + } + } + + private void showDenialDialog(@StringRes int title, @StringRes int body, + OnClickListener onOkClicked) { + AlertDialog.Builder builder = new AlertDialog.Builder(ctx); + builder.setTitle(title); + builder.setMessage(body); + builder.setPositiveButton(R.string.ok, onOkClicked); + builder.setNegativeButton(R.string.cancel, + (dialog, which) -> ctx.supportFinishAfterTransition()); + builder.show(); + } + + private void showRationale(@StringRes int title, @StringRes int body, + Runnable onContinueClicked) { + AlertDialog.Builder builder = new AlertDialog.Builder(ctx); + builder.setTitle(title); + builder.setMessage(body); + builder.setNeutralButton(R.string.continue_button, + (dialog, which) -> onContinueClicked.run()); + builder.show(); + } + + private void requestPermissions() { + locationRequest.launch(ACCESS_FINE_LOCATION); + } + + private void requestEnableWiFi() { + Intent i = SDK_INT < 29 ? + new Intent(Settings.ACTION_WIFI_SETTINGS) : + new Intent(Settings.Panel.ACTION_WIFI); + wifiRequest.launch(i); + } + +} diff --git a/briar-android/src/main/java/org/briarproject/briar/android/hotspot/HotspotActivity.java b/briar-android/src/main/java/org/briarproject/briar/android/hotspot/HotspotActivity.java index 1d3076520..32a8e79ee 100644 --- a/briar-android/src/main/java/org/briarproject/briar/android/hotspot/HotspotActivity.java +++ b/briar-android/src/main/java/org/briarproject/briar/android/hotspot/HotspotActivity.java @@ -3,19 +3,25 @@ package org.briarproject.briar.android.hotspot; import android.content.Intent; import android.os.Bundle; import android.view.MenuItem; +import android.widget.Toast; import org.briarproject.bramble.api.nullsafety.MethodsNotNullByDefault; import org.briarproject.bramble.api.nullsafety.ParametersNotNullByDefault; import org.briarproject.briar.R; import org.briarproject.briar.android.activity.ActivityComponent; import org.briarproject.briar.android.activity.BriarActivity; +import org.briarproject.briar.android.hotspot.HotspotState.HotspotError; +import org.briarproject.briar.android.hotspot.HotspotState.HotspotStarted; import javax.annotation.Nullable; import javax.inject.Inject; import androidx.appcompat.app.ActionBar; +import androidx.fragment.app.FragmentManager; import androidx.lifecycle.ViewModelProvider; +import static android.widget.Toast.LENGTH_LONG; +import static org.briarproject.briar.android.util.UiUtils.showFragment; import static org.briarproject.briar.api.android.AndroidNotificationManager.ACTION_STOP_HOTSPOT; @MethodsNotNullByDefault @@ -44,7 +50,21 @@ public class HotspotActivity extends BriarActivity { ab.setDisplayHomeAsUpEnabled(true); } - // TODO observe viewmodel state and show error or HotspotFragment + viewModel.getState().observe(this, hotspotState -> { + if (hotspotState instanceof HotspotStarted) { + FragmentManager fm = getSupportFragmentManager(); + String tag = HotspotFragment.TAG; + // check if fragment is already added + // to not lose state on configuration changes + if (fm.findFragmentByTag(tag) == null) { + showFragment(fm, new HotspotFragment(), tag); + } + } else if (hotspotState instanceof HotspotError) { + // TODO ErrorFragment + String error = ((HotspotError) hotspotState).getError(); + Toast.makeText(this, error, LENGTH_LONG).show(); + } + }); if (state == null) { getSupportFragmentManager().beginTransaction() diff --git a/briar-android/src/main/java/org/briarproject/briar/android/hotspot/HotspotFragment.java b/briar-android/src/main/java/org/briarproject/briar/android/hotspot/HotspotFragment.java index a85c4fbb2..4c145a015 100644 --- a/briar-android/src/main/java/org/briarproject/briar/android/hotspot/HotspotFragment.java +++ b/briar-android/src/main/java/org/briarproject/briar/android/hotspot/HotspotFragment.java @@ -5,11 +5,12 @@ import android.view.View; import org.briarproject.bramble.api.nullsafety.MethodsNotNullByDefault; import org.briarproject.bramble.api.nullsafety.ParametersNotNullByDefault; -import org.briarproject.briar.R; import androidx.annotation.Nullable; import androidx.fragment.app.Fragment; +import static org.briarproject.briar.android.util.UiUtils.showFragment; + @MethodsNotNullByDefault @ParametersNotNullByDefault public class HotspotFragment extends AbstractTabsFragment { @@ -21,14 +22,9 @@ public class HotspotFragment extends AbstractTabsFragment { super.onViewCreated(view, savedInstanceState); // no need to call into the ViewModel here connectedButton.setOnClickListener(v -> { - getParentFragmentManager().beginTransaction() - .setCustomAnimations(R.anim.step_next_in, - R.anim.step_previous_out, R.anim.step_previous_in, - R.anim.step_next_out) - .replace(R.id.fragmentContainer, new WebsiteFragment(), - WebsiteFragment.TAG) - .addToBackStack(WebsiteFragment.TAG) - .commit(); + Fragment f = new WebsiteFragment(); + String tag = WebsiteFragment.TAG; + showFragment(getParentFragmentManager(), f, tag); }); } diff --git a/briar-android/src/main/java/org/briarproject/briar/android/hotspot/HotspotIntroFragment.java b/briar-android/src/main/java/org/briarproject/briar/android/hotspot/HotspotIntroFragment.java index ab32ac6c6..b1b34fe5a 100644 --- a/briar-android/src/main/java/org/briarproject/briar/android/hotspot/HotspotIntroFragment.java +++ b/briar-android/src/main/java/org/briarproject/briar/android/hotspot/HotspotIntroFragment.java @@ -1,6 +1,9 @@ package org.briarproject.briar.android.hotspot; import android.content.Context; +import android.content.Intent; +import android.content.pm.ApplicationInfo; +import android.content.pm.PackageManager; import android.os.Bundle; import android.view.LayoutInflater; import android.view.View; @@ -9,19 +12,27 @@ import android.widget.Button; import android.widget.ProgressBar; import android.widget.TextView; +import com.google.android.material.snackbar.Snackbar; + import org.briarproject.bramble.api.nullsafety.MethodsNotNullByDefault; import org.briarproject.bramble.api.nullsafety.ParametersNotNullByDefault; import org.briarproject.briar.R; import javax.inject.Inject; +import androidx.activity.result.ActivityResultLauncher; +import androidx.activity.result.contract.ActivityResultContracts.RequestPermission; +import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult; import androidx.annotation.Nullable; import androidx.fragment.app.Fragment; +import androidx.fragment.app.FragmentActivity; import androidx.lifecycle.ViewModelProvider; +import static android.content.pm.ApplicationInfo.FLAG_TEST_ONLY; import static android.view.View.INVISIBLE; import static android.view.View.VISIBLE; import static androidx.transition.TransitionManager.beginDelayedTransition; +import static com.google.android.material.snackbar.BaseTransientBottomBar.LENGTH_LONG; import static org.briarproject.briar.android.AppModule.getAndroidComponent; @MethodsNotNullByDefault @@ -34,13 +45,32 @@ public class HotspotIntroFragment extends Fragment { ViewModelProvider.Factory viewModelFactory; private HotspotViewModel viewModel; + private ConditionManager conditionManager; + + private Button startButton; + private ProgressBar progressBar; + private TextView progressTextView; + + private final ActivityResultLauncher locationRequest = + registerForActivityResult(new RequestPermission(), granted -> { + conditionManager.onRequestPermissionResult(granted); + startHotspot(); + }); + private final ActivityResultLauncher wifiRequest = + registerForActivityResult(new StartActivityForResult(), result -> { + conditionManager.onRequestWifiEnabledResult(); + startHotspot(); + }); @Override public void onAttach(Context context) { super.onAttach(context); - getAndroidComponent(requireContext()).inject(this); - viewModel = new ViewModelProvider(requireActivity(), viewModelFactory) + FragmentActivity activity = requireActivity(); + getAndroidComponent(activity).inject(this); + viewModel = new ViewModelProvider(activity, viewModelFactory) .get(HotspotViewModel.class); + conditionManager = + new ConditionManager(activity, locationRequest, wifiRequest); } @Override @@ -50,31 +80,48 @@ public class HotspotIntroFragment extends Fragment { View v = inflater .inflate(R.layout.fragment_hotspot_intro, container, false); - Button startButton = v.findViewById(R.id.startButton); - ProgressBar progressBar = v.findViewById(R.id.progressBar); - TextView progressTextView = v.findViewById(R.id.progressTextView); + startButton = v.findViewById(R.id.startButton); + progressBar = v.findViewById(R.id.progressBar); + progressTextView = v.findViewById(R.id.progressTextView); - startButton.setOnClickListener(button -> { - beginDelayedTransition((ViewGroup) v); - startButton.setVisibility(INVISIBLE); - progressBar.setVisibility(VISIBLE); - progressTextView.setVisibility(VISIBLE); - // TODO remove below, tell viewModel to start hotspot instead - v.postDelayed(() -> { - viewModel.startHotspot(); - getParentFragmentManager().beginTransaction() - .setCustomAnimations(R.anim.step_next_in, - R.anim.step_previous_out, - R.anim.step_previous_in, - R.anim.step_next_out) - .replace(R.id.fragmentContainer, new HotspotFragment(), - HotspotFragment.TAG) - .addToBackStack(HotspotFragment.TAG) - .commit(); - }, 1500); - }); + startButton.setOnClickListener( + button -> conditionManager.startConditionChecks()); return v; } + @Override + public void onStart() { + super.onStart(); + conditionManager.resetPermissions(); + } + + private void startHotspot() { + if (conditionManager.checkAndRequestConditions()) { + showInstallWarningIfNeeded(); + beginDelayedTransition((ViewGroup) requireView()); + startButton.setVisibility(INVISIBLE); + progressBar.setVisibility(VISIBLE); + progressTextView.setVisibility(VISIBLE); + viewModel.startHotspot(); + } + } + + private void showInstallWarningIfNeeded() { + Context ctx = requireContext(); + ApplicationInfo applicationInfo; + try { + applicationInfo = ctx.getPackageManager() + .getApplicationInfo(ctx.getPackageName(), 0); + } catch (PackageManager.NameNotFoundException e) { + throw new AssertionError(e); + } + // test only apps can not be installed + if ((applicationInfo.flags & FLAG_TEST_ONLY) == FLAG_TEST_ONLY) { + int color = getResources().getColor(R.color.briar_red_500); + Snackbar.make(requireView(), R.string.hotspot_flag_test, + LENGTH_LONG).setBackgroundTint(color).show(); + } + } + } diff --git a/briar-android/src/main/java/org/briarproject/briar/android/hotspot/HotspotManager.java b/briar-android/src/main/java/org/briarproject/briar/android/hotspot/HotspotManager.java new file mode 100644 index 000000000..fb618c1c8 --- /dev/null +++ b/briar-android/src/main/java/org/briarproject/briar/android/hotspot/HotspotManager.java @@ -0,0 +1,299 @@ +package org.briarproject.briar.android.hotspot; + +import android.content.Context; +import android.graphics.Bitmap; +import android.net.wifi.WifiManager; +import android.net.wifi.p2p.WifiP2pConfig; +import android.net.wifi.p2p.WifiP2pGroup; +import android.net.wifi.p2p.WifiP2pManager; +import android.net.wifi.p2p.WifiP2pManager.ActionListener; +import android.net.wifi.p2p.WifiP2pManager.GroupInfoListener; +import android.os.Handler; +import android.util.DisplayMetrics; + +import org.briarproject.bramble.api.lifecycle.IoExecutor; +import org.briarproject.briar.R; +import org.briarproject.briar.android.hotspot.HotspotState.NetworkConfig; +import org.briarproject.briar.android.util.QrCodeUtils; + +import java.security.SecureRandom; +import java.util.concurrent.Executor; +import java.util.logging.Logger; + +import androidx.annotation.Nullable; +import androidx.annotation.RequiresApi; +import androidx.annotation.UiThread; + +import static android.content.Context.WIFI_P2P_SERVICE; +import static android.content.Context.WIFI_SERVICE; +import static android.net.wifi.WifiManager.WIFI_MODE_FULL; +import static android.net.wifi.WifiManager.WIFI_MODE_FULL_HIGH_PERF; +import static android.net.wifi.p2p.WifiP2pConfig.GROUP_OWNER_BAND_2GHZ; +import static android.net.wifi.p2p.WifiP2pManager.BUSY; +import static android.net.wifi.p2p.WifiP2pManager.ERROR; +import static android.net.wifi.p2p.WifiP2pManager.NO_SERVICE_REQUESTS; +import static android.net.wifi.p2p.WifiP2pManager.P2P_UNSUPPORTED; +import static android.os.Build.VERSION.SDK_INT; +import static java.util.logging.Level.INFO; +import static java.util.logging.Logger.getLogger; + +class HotspotManager implements ActionListener { + + interface HotspotListener { + void onStartingHotspot(); + + @IoExecutor + void onHotspotStarted(NetworkConfig networkConfig); + + void onHotspotStopped(); + + void onHotspotError(String error); + } + + private static final Logger LOG = getLogger(HotspotManager.class.getName()); + + private static final int MAX_GROUP_INFO_ATTEMPTS = 5; + private static final int RETRY_DELAY_MILLIS = 1000; + + private final Context ctx; + @IoExecutor + private final Executor ioExecutor; + private final SecureRandom random; + private final HotspotListener listener; + private final WifiManager wifiManager; + private final WifiP2pManager wifiP2pManager; + private final Handler handler; + private final String lockTag; + + @Nullable + // on API < 29 this is null because we cannot request a custom network name + private String networkName = null; + + private WifiManager.WifiLock wifiLock; + private WifiP2pManager.Channel channel; + + HotspotManager(Context ctx, @IoExecutor Executor ioExecutor, + SecureRandom random, HotspotListener listener) { + this.ctx = ctx; + this.ioExecutor = ioExecutor; + this.random = random; + this.listener = listener; + wifiManager = (WifiManager) ctx.getApplicationContext() + .getSystemService(WIFI_SERVICE); + wifiP2pManager = + (WifiP2pManager) ctx.getSystemService(WIFI_P2P_SERVICE); + handler = new Handler(ctx.getMainLooper()); + lockTag = ctx.getPackageName() + ":app-sharing-hotspot"; + } + + @UiThread + void startWifiP2pHotspot() { + if (wifiP2pManager == null) { + listener.onHotspotError( + ctx.getString(R.string.hotspot_error_no_wifi_direct)); + return; + } + listener.onStartingHotspot(); + channel = wifiP2pManager.initialize(ctx, ctx.getMainLooper(), null); + if (channel == null) { + listener.onHotspotError( + ctx.getString(R.string.hotspot_error_no_wifi_direct)); + return; + } + acquireLock(); + try { + if (SDK_INT >= 29) { + networkName = getNetworkName(); + String passphrase = getPassphrase(); + WifiP2pConfig config = new WifiP2pConfig.Builder() + .setGroupOperatingBand(GROUP_OWNER_BAND_2GHZ) + .setNetworkName(networkName) + .setPassphrase(passphrase) + .build(); + wifiP2pManager.createGroup(channel, config, this); + } else { + wifiP2pManager.createGroup(channel, this); + } + } catch (SecurityException e) { + // this should never happen, because we request permissions before + throw new AssertionError(e); + } + } + + @Override + // Callback for wifiP2pManager#createGroup() during startWifiP2pHotspot() + public void onSuccess() { + requestGroupInfo(1); + } + + @Override + // Callback for wifiP2pManager#createGroup() during startWifiP2pHotspot() + public void onFailure(int reason) { + if (reason == BUSY) { + // Hotspot already running + requestGroupInfo(1); + } else if (reason == P2P_UNSUPPORTED) { + releaseHotspotWithError(ctx.getString( + R.string.hotspot_error_start_callback_failed, + "p2p unsupported")); + } else if (reason == ERROR) { + releaseHotspotWithError(ctx.getString( + R.string.hotspot_error_start_callback_failed, "p2p error")); + } else if (reason == NO_SERVICE_REQUESTS) { + releaseHotspotWithError(ctx.getString( + R.string.hotspot_error_start_callback_failed, + "no service requests")); + } else { + // all cases covered, in doubt set to error + releaseHotspotWithError(ctx.getString( + R.string.hotspot_error_start_callback_failed_unknown, + reason)); + } + } + + @RequiresApi(29) + private String getNetworkName() { + return "DIRECT-" + getRandomString(2) + "-" + + getRandomString(10); + } + + private String getPassphrase() { + return getRandomString(8); + } + + void stopWifiP2pHotspot() { + if (channel == null) return; + wifiP2pManager.removeGroup(channel, new ActionListener() { + @Override + public void onSuccess() { + releaseHotspot(); + } + + @Override + public void onFailure(int reason) { + // not propagating back error + releaseHotspot(); + } + }); + } + + private void acquireLock() { + // WIFI_MODE_FULL has no effect on API >= 29 + int lockType = + SDK_INT >= 29 ? WIFI_MODE_FULL_HIGH_PERF : WIFI_MODE_FULL; + wifiLock = wifiManager.createWifiLock(lockType, lockTag); + wifiLock.acquire(); + } + + private void releaseHotspot() { + listener.onHotspotStopped(); + closeChannelAndReleaseLock(); + } + + private void releaseHotspotWithError(String error) { + listener.onHotspotError(error); + closeChannelAndReleaseLock(); + } + + private void closeChannelAndReleaseLock() { + if (SDK_INT >= 27) channel.close(); + channel = null; + wifiLock.release(); + } + + private void requestGroupInfo(int attempt) { + if (LOG.isLoggable(INFO)) { + LOG.info("requestGroupInfo attempt: " + attempt); + } + GroupInfoListener groupListener = group -> { + boolean valid = isGroupValid(group); + // If the group is valid, set the hotspot to started. If we don't + // have any attempts left, we try what we got + if (valid || attempt >= MAX_GROUP_INFO_ATTEMPTS) { + onHotspotStarted(group); + } else { + retryRequestingGroupInfo(attempt + 1); + } + }; + try { + wifiP2pManager.requestGroupInfo(channel, groupListener); + } catch (SecurityException e) { + // this should never happen, because we request permissions before + throw new AssertionError(e); + } + } + + private void onHotspotStarted(WifiP2pGroup group) { + DisplayMetrics dm = ctx.getResources().getDisplayMetrics(); + ioExecutor.execute(() -> { + String content = createWifiLoginString(group.getNetworkName(), + group.getPassphrase()); + Bitmap qrCode = QrCodeUtils.createQrCode(dm, content); + NetworkConfig config = new NetworkConfig(group.getNetworkName(), + group.getPassphrase(), qrCode); + listener.onHotspotStarted(config); + }); + } + + private boolean isGroupValid(@Nullable WifiP2pGroup group) { + if (group == null) { + LOG.info("group is null"); + return false; + } else if (!group.getNetworkName().startsWith("DIRECT-")) { + if (LOG.isLoggable(INFO)) { + LOG.info("received networkName without prefix 'DIRECT-': " + + group.getNetworkName()); + } + return false; + } else if (networkName != null && + !networkName.equals(group.getNetworkName())) { + if (LOG.isLoggable(INFO)) { + LOG.info("expected networkName: " + networkName); + LOG.info("received networkName: " + group.getNetworkName()); + } + return false; + } + return true; + } + + private void retryRequestingGroupInfo(int attempt) { + LOG.info("retrying"); + // On some devices we need to wait for the group info to become available + if (attempt < MAX_GROUP_INFO_ATTEMPTS) { + handler.postDelayed(() -> requestGroupInfo(attempt + 1), + RETRY_DELAY_MILLIS); + } else { + releaseHotspotWithError(ctx.getString( + R.string.hotspot_error_start_callback_no_group_info)); + } + } + + private static String createWifiLoginString(String ssid, String password) { + // https://en.wikipedia.org/wiki/QR_code#WiFi_network_login + // do not remove the dangling ';', it can cause problems to omit it + return "WIFI:S:" + ssid + ";T:WPA;P:" + password + ";;"; + } + + private static final String digits = "123456789"; // avoid 0 + private static final String letters = "abcdefghijkmnopqrstuvwxyz"; // no l + private static final String LETTERS = "ABCDEFGHJKLMNPQRSTUVWXYZ"; // no I, O + + private String getRandomString(int length) { + char[] c = new char[length]; + for (int i = 0; i < length; i++) { + if (random.nextBoolean()) { + c[i] = random(digits); + } else if (random.nextBoolean()) { + c[i] = random(letters); + } else { + c[i] = random(LETTERS); + } + } + return new String(c); + } + + private char random(String universe) { + return universe.charAt(random.nextInt(universe.length())); + } + +} diff --git a/briar-android/src/main/java/org/briarproject/briar/android/hotspot/HotspotState.java b/briar-android/src/main/java/org/briarproject/briar/android/hotspot/HotspotState.java new file mode 100644 index 000000000..40e620fcc --- /dev/null +++ b/briar-android/src/main/java/org/briarproject/briar/android/hotspot/HotspotState.java @@ -0,0 +1,66 @@ +package org.briarproject.briar.android.hotspot; + +import android.graphics.Bitmap; + +import androidx.annotation.Nullable; + +abstract class HotspotState { + + static class StartingHotspot extends HotspotState { + } + + static class NetworkConfig { + final String ssid, password; + @Nullable + final Bitmap qrCode; + + NetworkConfig(String ssid, String password, @Nullable Bitmap qrCode) { + this.ssid = ssid; + this.password = password; + this.qrCode = qrCode; + } + } + + static class WebsiteConfig { + final String url; + @Nullable + final Bitmap qrCode; + + WebsiteConfig(String url, @Nullable Bitmap qrCode) { + this.url = url; + this.qrCode = qrCode; + } + } + + static class HotspotStarted extends HotspotState { + private final NetworkConfig networkConfig; + private final WebsiteConfig websiteConfig; + + HotspotStarted(NetworkConfig networkConfig, + WebsiteConfig websiteConfig) { + this.networkConfig = networkConfig; + this.websiteConfig = websiteConfig; + } + + NetworkConfig getNetworkConfig() { + return networkConfig; + } + + WebsiteConfig getWebsiteConfig() { + return websiteConfig; + } + } + + static class HotspotError extends HotspotState { + private final String error; + + HotspotError(String error) { + this.error = error; + } + + String getError() { + return error; + } + } + +} diff --git a/briar-android/src/main/java/org/briarproject/briar/android/hotspot/HotspotViewModel.java b/briar-android/src/main/java/org/briarproject/briar/android/hotspot/HotspotViewModel.java index 4dfc3c733..e4b13a598 100644 --- a/briar-android/src/main/java/org/briarproject/briar/android/hotspot/HotspotViewModel.java +++ b/briar-android/src/main/java/org/briarproject/briar/android/hotspot/HotspotViewModel.java @@ -4,37 +4,82 @@ import android.app.Application; import org.briarproject.bramble.api.db.DatabaseExecutor; import org.briarproject.bramble.api.db.TransactionManager; +import org.briarproject.bramble.api.lifecycle.IoExecutor; import org.briarproject.bramble.api.lifecycle.LifecycleManager; import org.briarproject.bramble.api.nullsafety.NotNullByDefault; import org.briarproject.bramble.api.system.AndroidExecutor; +import org.briarproject.briar.R; +import org.briarproject.briar.android.hotspot.HotspotManager.HotspotListener; +import org.briarproject.briar.android.hotspot.HotspotState.HotspotError; +import org.briarproject.briar.android.hotspot.HotspotState.HotspotStarted; +import org.briarproject.briar.android.hotspot.HotspotState.NetworkConfig; +import org.briarproject.briar.android.hotspot.HotspotState.StartingHotspot; +import org.briarproject.briar.android.hotspot.HotspotState.WebsiteConfig; +import org.briarproject.briar.android.hotspot.WebServerManager.WebServerListener; import org.briarproject.briar.android.viewmodel.DbViewModel; import org.briarproject.briar.api.android.AndroidNotificationManager; +import java.security.SecureRandom; import java.util.concurrent.Executor; +import java.util.logging.Logger; import javax.inject.Inject; -@NotNullByDefault -class HotspotViewModel extends DbViewModel { +import androidx.annotation.Nullable; +import androidx.annotation.UiThread; +import androidx.lifecycle.LiveData; +import androidx.lifecycle.MutableLiveData; +import static java.util.logging.Logger.getLogger; + +@NotNullByDefault +class HotspotViewModel extends DbViewModel + implements HotspotListener, WebServerListener { + + private static final Logger LOG = + getLogger(HotspotViewModel.class.getName()); + + @IoExecutor + private final Executor ioExecutor; private final AndroidNotificationManager notificationManager; + private final HotspotManager hotspotManager; + private final WebServerManager webServerManager; + + private final MutableLiveData state = + new MutableLiveData<>(); + + @Nullable + // Field to temporarily store the network config received via onHotspotStarted() + // in order to post it along with a HotspotStarted status + private volatile NetworkConfig networkConfig; @Inject - HotspotViewModel(Application application, + HotspotViewModel(Application app, @DatabaseExecutor Executor dbExecutor, LifecycleManager lifecycleManager, TransactionManager db, AndroidExecutor androidExecutor, + @IoExecutor Executor ioExecutor, + SecureRandom secureRandom, AndroidNotificationManager notificationManager) { - super(application, dbExecutor, lifecycleManager, db, androidExecutor); + super(app, dbExecutor, lifecycleManager, db, androidExecutor); + this.ioExecutor = ioExecutor; this.notificationManager = notificationManager; + hotspotManager = + new HotspotManager(app, ioExecutor, secureRandom, this); + webServerManager = new WebServerManager(app, this); } + @UiThread void startHotspot() { + hotspotManager.startWifiP2pHotspot(); notificationManager.showHotspotNotification(); } + @UiThread private void stopHotspot() { + ioExecutor.execute(webServerManager::stopWebServer); + hotspotManager.stopWifiP2pHotspot(); notificationManager.clearHotspotNotification(); } @@ -44,6 +89,49 @@ class HotspotViewModel extends DbViewModel { stopHotspot(); } - // TODO copy actual code from Offline Hotspot app + @Override + public void onStartingHotspot() { + state.setValue(new StartingHotspot()); + } + + @Override + @IoExecutor + public void onHotspotStarted(NetworkConfig networkConfig) { + this.networkConfig = networkConfig; + LOG.info("starting webserver"); + webServerManager.startWebServer(); + } + + @Override + public void onHotspotStopped() { + LOG.info("stopping webserver"); + ioExecutor.execute(webServerManager::stopWebServer); + } + + @Override + public void onHotspotError(String error) { + state.setValue(new HotspotError(error)); + ioExecutor.execute(webServerManager::stopWebServer); + notificationManager.clearHotspotNotification(); + } + + @Override + @IoExecutor + public void onWebServerStarted(WebsiteConfig websiteConfig) { + state.postValue(new HotspotStarted(networkConfig, websiteConfig)); + networkConfig = null; + } + + @Override + @IoExecutor + public void onWebServerError() { + state.postValue(new HotspotError(getApplication() + .getString(R.string.hotspot_error_web_server_start))); + hotspotManager.stopWifiP2pHotspot(); + } + + LiveData getState() { + return state; + } } diff --git a/briar-android/src/main/java/org/briarproject/briar/android/hotspot/ManualHotspotFragment.java b/briar-android/src/main/java/org/briarproject/briar/android/hotspot/ManualHotspotFragment.java index d8ea980fd..f049eab18 100644 --- a/briar-android/src/main/java/org/briarproject/briar/android/hotspot/ManualHotspotFragment.java +++ b/briar-android/src/main/java/org/briarproject/briar/android/hotspot/ManualHotspotFragment.java @@ -14,12 +14,14 @@ import org.briarproject.briar.R; import javax.inject.Inject; import androidx.annotation.Nullable; +import androidx.core.util.Consumer; import androidx.fragment.app.Fragment; import androidx.lifecycle.ViewModelProvider; import static android.view.View.GONE; import static org.briarproject.briar.android.AppModule.getAndroidComponent; import static org.briarproject.briar.android.hotspot.AbstractTabsFragment.ARG_FOR_WIFI_CONNECT; +import static org.briarproject.briar.android.hotspot.HotspotState.HotspotStarted; @MethodsNotNullByDefault @ParametersNotNullByDefault @@ -66,21 +68,28 @@ public class ManualHotspotFragment extends Fragment { TextView passwordView = v.findViewById(R.id.passwordView); TextView altView = v.findViewById(R.id.altView); + Consumer consumer; if (requireArguments().getBoolean(ARG_FOR_WIFI_CONNECT)) { manualIntroView.setText(R.string.hotspot_manual_wifi); ssidLabelView.setText(R.string.hotspot_manual_wifi_ssid); - // TODO observe state in ViewModel and get info from there instead - ssidView.setText("DIRECT-42-dfzsgf34ef"); - passwordView.setText("sdf78shfd8"); + consumer = state -> { + ssidView.setText(state.getNetworkConfig().ssid); + passwordView.setText(state.getNetworkConfig().password); + }; altView.setText(R.string.hotspot_manual_wifi_alt); } else { manualIntroView.setText(R.string.hotspot_manual_site); ssidLabelView.setText(R.string.hotspot_manual_site_address); - // TODO observe state in ViewModel and get info from there instead - ssidView.setText("http://192.168.49.1:9999"); + consumer = state -> ssidView.setText(state.getWebsiteConfig().url); altView.setText(R.string.hotspot_manual_site_alt); v.findViewById(R.id.passwordLabelView).setVisibility(GONE); passwordView.setVisibility(GONE); } + viewModel.getState().observe(getViewLifecycleOwner(), state -> { + // we only expect to be in this state here + if (state instanceof HotspotStarted) { + consumer.accept((HotspotStarted) state); + } + }); } } diff --git a/briar-android/src/main/java/org/briarproject/briar/android/hotspot/QrHotspotFragment.java b/briar-android/src/main/java/org/briarproject/briar/android/hotspot/QrHotspotFragment.java index 84a56c8bd..f56f51580 100644 --- a/briar-android/src/main/java/org/briarproject/briar/android/hotspot/QrHotspotFragment.java +++ b/briar-android/src/main/java/org/briarproject/briar/android/hotspot/QrHotspotFragment.java @@ -11,10 +11,12 @@ import android.widget.TextView; import org.briarproject.bramble.api.nullsafety.MethodsNotNullByDefault; import org.briarproject.bramble.api.nullsafety.ParametersNotNullByDefault; import org.briarproject.briar.R; +import org.briarproject.briar.android.hotspot.HotspotState.HotspotStarted; import javax.inject.Inject; import androidx.annotation.Nullable; +import androidx.core.util.Consumer; import androidx.fragment.app.Fragment; import androidx.lifecycle.ViewModelProvider; @@ -58,13 +60,21 @@ public class QrHotspotFragment extends Fragment { TextView qrIntroView = v.findViewById(R.id.qrIntroView); ImageView qrCodeView = v.findViewById(R.id.qrCodeView); + Consumer consumer; if (requireArguments().getBoolean(ARG_FOR_WIFI_CONNECT)) { qrIntroView.setText(R.string.hotspot_qr_wifi); - // TODO observe state in ViewModel and get QR code from there + consumer = state -> + qrCodeView.setImageBitmap(state.getNetworkConfig().qrCode); } else { qrIntroView.setText(R.string.hotspot_qr_site); - // TODO observe state in ViewModel and get QR code from there + consumer = state -> + qrCodeView.setImageBitmap(state.getWebsiteConfig().qrCode); } + viewModel.getState().observe(getViewLifecycleOwner(), state -> { + if (state instanceof HotspotStarted) { + consumer.accept((HotspotStarted) state); + } + }); return v; } diff --git a/briar-android/src/main/java/org/briarproject/briar/android/hotspot/WebServer.java b/briar-android/src/main/java/org/briarproject/briar/android/hotspot/WebServer.java new file mode 100644 index 000000000..14dfba349 --- /dev/null +++ b/briar-android/src/main/java/org/briarproject/briar/android/hotspot/WebServer.java @@ -0,0 +1,130 @@ +package org.briarproject.briar.android.hotspot; + +import android.content.Context; + +import org.briarproject.briar.R; +import org.jsoup.Jsoup; +import org.jsoup.nodes.Document; + +import java.io.File; +import java.io.FileInputStream; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.InputStream; +import java.util.logging.Logger; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import androidx.annotation.Nullable; +import fi.iki.elonen.NanoHTTPD; + +import static android.util.Xml.Encoding.UTF_8; +import static fi.iki.elonen.NanoHTTPD.Response.Status.INTERNAL_ERROR; +import static fi.iki.elonen.NanoHTTPD.Response.Status.NOT_FOUND; +import static fi.iki.elonen.NanoHTTPD.Response.Status.OK; +import static java.util.Objects.requireNonNull; +import static java.util.logging.Level.WARNING; +import static java.util.logging.Logger.getLogger; +import static org.briarproject.bramble.util.LogUtils.logException; +import static org.briarproject.briar.BuildConfig.VERSION_NAME; + +public class WebServer extends NanoHTTPD { + + final static int PORT = 9999; + + private static final Logger LOG = getLogger(WebServer.class.getName()); + private static final String FILE_HTML = "hotspot.html"; + private static final Pattern REGEX_AGENT = + Pattern.compile("Android ([0-9]+)"); + + private final Context ctx; + + WebServer(Context ctx) { + super(PORT); + this.ctx = ctx; + } + + @Override + public void start() throws IOException { + start(NanoHTTPD.SOCKET_READ_TIMEOUT, false); + } + + @Override + public Response serve(IHTTPSession session) { + if (session.getUri().endsWith("favicon.ico")) { + return newFixedLengthResponse(NOT_FOUND, MIME_PLAINTEXT, + NOT_FOUND.getDescription()); + } + if (session.getUri().endsWith(".apk")) { + return serveApk(); + } + Response res; + try { + String html = getHtml(session.getHeaders().get("user-agent")); + res = newFixedLengthResponse(OK, MIME_HTML, html); + } catch (Exception e) { + logException(LOG, WARNING, e); + res = newFixedLengthResponse(INTERNAL_ERROR, MIME_PLAINTEXT, + INTERNAL_ERROR.getDescription()); + } + return res; + } + + private String getHtml(@Nullable String userAgent) throws Exception { + Document doc; + try (InputStream is = ctx.getAssets().open(FILE_HTML)) { + doc = Jsoup.parse(is, UTF_8.name(), ""); + } + String app = ctx.getString(R.string.app_name); + String appV = app + " " + VERSION_NAME; + doc.select("#download_title").first() + .text(ctx.getString(R.string.website_download_title, appV)); + doc.select("#download_intro").first() + .text(ctx.getString(R.string.website_download_intro, app)); + doc.select("#download_button").first() + .text(ctx.getString(R.string.website_download_title, app)); + doc.select("#download_outro").first() + .text(ctx.getString(R.string.website_download_outro)); + doc.select("#troubleshooting_title").first() + .text(ctx.getString(R.string.website_troubleshooting_title)); + doc.select("#troubleshooting_1").first() + .text(ctx.getString(R.string.website_troubleshooting_1)); + doc.select("#troubleshooting_2").first() + .text(getUnknownSourcesString(userAgent)); + return doc.outerHtml(); + } + + private String getUnknownSourcesString(String userAgent) { + boolean is8OrHigher = false; + if (userAgent != null) { + Matcher matcher = REGEX_AGENT.matcher(userAgent); + if (matcher.find()) { + int androidMajorVersion = + Integer.parseInt(requireNonNull(matcher.group(1))); + is8OrHigher = androidMajorVersion >= 8; + } + } + return is8OrHigher ? + ctx.getString(R.string.website_troubleshooting_2_new) : + ctx.getString(R.string.website_troubleshooting_2_old); + } + + private Response serveApk() { + String mime = "application/vnd.android.package-archive"; + + File file = new File(ctx.getPackageCodePath()); + long fileLen = file.length(); + + Response res; + try { + FileInputStream fis = new FileInputStream(file); + res = newFixedLengthResponse(OK, mime, fis, fileLen); + res.addHeader("Content-Length", "" + fileLen); + } catch (FileNotFoundException e) { + logException(LOG, WARNING, e); + res = newFixedLengthResponse(NOT_FOUND, MIME_PLAINTEXT, + "Error 404, file not found."); + } + return res; + } +} diff --git a/briar-android/src/main/java/org/briarproject/briar/android/hotspot/WebServerManager.java b/briar-android/src/main/java/org/briarproject/briar/android/hotspot/WebServerManager.java new file mode 100644 index 000000000..cb01e8c1d --- /dev/null +++ b/briar-android/src/main/java/org/briarproject/briar/android/hotspot/WebServerManager.java @@ -0,0 +1,114 @@ +package org.briarproject.briar.android.hotspot; + +import android.content.Context; +import android.graphics.Bitmap; +import android.util.DisplayMetrics; + +import org.briarproject.bramble.api.lifecycle.IoExecutor; +import org.briarproject.briar.android.hotspot.HotspotState.WebsiteConfig; +import org.briarproject.briar.android.util.QrCodeUtils; + +import java.io.IOException; +import java.net.InetAddress; +import java.net.InterfaceAddress; +import java.net.NetworkInterface; +import java.net.SocketException; +import java.util.Enumeration; +import java.util.List; +import java.util.logging.Logger; + +import androidx.annotation.Nullable; + +import static java.util.Collections.emptyList; +import static java.util.Collections.list; +import static java.util.logging.Level.INFO; +import static java.util.logging.Level.WARNING; +import static java.util.logging.Logger.getLogger; +import static org.briarproject.bramble.util.LogUtils.logException; +import static org.briarproject.briar.android.hotspot.WebServer.PORT; + +class WebServerManager { + + interface WebServerListener { + @IoExecutor + void onWebServerStarted(WebsiteConfig websiteConfig); + + @IoExecutor + void onWebServerError(); + } + + private static final Logger LOG = + getLogger(WebServerManager.class.getName()); + + private final WebServer webServer; + private final WebServerListener listener; + private final DisplayMetrics dm; + + WebServerManager(Context ctx, WebServerListener listener) { + this.listener = listener; + webServer = new WebServer(ctx); + dm = ctx.getResources().getDisplayMetrics(); + } + + @IoExecutor + void startWebServer() { + try { + webServer.start(); + onWebServerStarted(); + } catch (IOException e) { + logException(LOG, WARNING, e); + listener.onWebServerError(); + } + } + + @IoExecutor + private void onWebServerStarted() { + String url = "http://192.168.49.1:" + PORT; + InetAddress address = getAccessPointAddress(); + if (address == null) { + LOG.info( + "Could not find access point address, assuming 192.168.49.1"); + } else { + if (LOG.isLoggable(INFO)) { + LOG.info("Access point address " + address.getHostAddress()); + } + url = "http://" + address.getHostAddress() + ":" + PORT; + } + Bitmap qrCode = QrCodeUtils.createQrCode(dm, url); + listener.onWebServerStarted(new WebsiteConfig(url, qrCode)); + } + + /** + * It is safe to call this more than once and it won't throw. + */ + @IoExecutor + void stopWebServer() { + webServer.stop(); + } + + @Nullable + private static InetAddress getAccessPointAddress() { + for (NetworkInterface i : getNetworkInterfaces()) { + if (i.getName().startsWith("p2p")) { + for (InterfaceAddress a : i.getInterfaceAddresses()) { + // we consider only IPv4 addresses + if (a.getAddress().getAddress().length == 4) + return a.getAddress(); + } + } + } + return null; + } + + private static List getNetworkInterfaces() { + try { + Enumeration ifaces = + NetworkInterface.getNetworkInterfaces(); + return ifaces == null ? emptyList() : list(ifaces); + } catch (SocketException e) { + logException(LOG, WARNING, e); + return emptyList(); + } + } + +} diff --git a/briar-android/src/main/java/org/briarproject/briar/android/contact/add/nearby/QrCodeUtils.java b/briar-android/src/main/java/org/briarproject/briar/android/util/QrCodeUtils.java similarity index 91% rename from briar-android/src/main/java/org/briarproject/briar/android/contact/add/nearby/QrCodeUtils.java rename to briar-android/src/main/java/org/briarproject/briar/android/util/QrCodeUtils.java index 26f2bf050..ae8fa55e6 100644 --- a/briar-android/src/main/java/org/briarproject/briar/android/contact/add/nearby/QrCodeUtils.java +++ b/briar-android/src/main/java/org/briarproject/briar/android/util/QrCodeUtils.java @@ -1,4 +1,4 @@ -package org.briarproject.briar.android.contact.add.nearby; +package org.briarproject.briar.android.util; import android.graphics.Bitmap; import android.util.DisplayMetrics; @@ -22,12 +22,12 @@ import static java.util.logging.Logger.getLogger; import static org.briarproject.bramble.util.LogUtils.logException; @NotNullByDefault -class QrCodeUtils { +public class QrCodeUtils { private static final Logger LOG = getLogger(QrCodeUtils.class.getName()); @Nullable - static Bitmap createQrCode(DisplayMetrics dm, String input) { + public static Bitmap createQrCode(DisplayMetrics dm, String input) { int smallestDimen = Math.min(dm.widthPixels, dm.heightPixels); try { // Generate QR code diff --git a/briar-android/src/main/java/org/briarproject/briar/android/util/UiUtils.java b/briar-android/src/main/java/org/briarproject/briar/android/util/UiUtils.java index aa77231d7..ab03767f3 100644 --- a/briar-android/src/main/java/org/briarproject/briar/android/util/UiUtils.java +++ b/briar-android/src/main/java/org/briarproject/briar/android/util/UiUtils.java @@ -58,7 +58,9 @@ import androidx.core.content.ContextCompat; import androidx.core.hardware.fingerprint.FingerprintManagerCompat; import androidx.core.text.HtmlCompat; import androidx.core.util.Consumer; +import androidx.fragment.app.Fragment; import androidx.fragment.app.FragmentActivity; +import androidx.fragment.app.FragmentManager; import androidx.lifecycle.LifecycleOwner; import androidx.lifecycle.LiveData; import androidx.lifecycle.Observer; @@ -139,6 +141,17 @@ public class UiUtils { imm.hideSoftInputFromWindow(view.getWindowToken(), 0); } + public static void showFragment(FragmentManager fm, Fragment f, + @Nullable String tag) { + fm.beginTransaction() + .setCustomAnimations(R.anim.step_next_in, + R.anim.step_previous_out, R.anim.step_previous_in, + R.anim.step_next_out) + .replace(R.id.fragmentContainer, f, tag) + .addToBackStack(tag) + .commit(); + } + public static String getContactDisplayName(Author author, @Nullable String alias) { String name = author.getName(); diff --git a/briar-android/src/main/res/layout/fragment_hotspot_qr.xml b/briar-android/src/main/res/layout/fragment_hotspot_qr.xml index 2cfa4932e..4a55b0641 100644 --- a/briar-android/src/main/res/layout/fragment_hotspot_qr.xml +++ b/briar-android/src/main/res/layout/fragment_hotspot_qr.xml @@ -17,35 +17,30 @@ android:layout_margin="16dp" android:text="@string/hotspot_qr_wifi" app:layout_constraintEnd_toEndOf="parent" - app:layout_constraintHorizontal_bias="0.5" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@+id/cardView" app:layout_constraintTop_toTopOf="parent" /> + app:layout_constraintVertical_bias="0.0"> + android:layout_width="wrap_content" + android:layout_height="wrap_content" + tools:ignore="ContentDescription" + tools:src="@tools:sample/avatars" /> diff --git a/briar-android/src/main/res/values/strings.xml b/briar-android/src/main/res/values/strings.xml index bfcf491cd..24b99bfd5 100644 --- a/briar-android/src/main/res/values/strings.xml +++ b/briar-android/src/main/res/values/strings.xml @@ -701,6 +701,20 @@ Wi-Fi hotspot Sharing Briar offline Confirm connection + + To create a Wi-Fi hotspot, Briar needs permission to access your location.\n\nBriar does not store your location or share it with anyone. + You have denied access to your location, but Briar needs this permission to create a Wi-Fi hotspot.\n\nPlease consider granting access. + Wi-Fi setting + To create a Wi-Fi hotspot, Briar needs to use Wi-Fi. Please enable it. + You have denied to enable Wi-Fi, but Briar needs to use Wi-Fi.\n\nPlease consider enabling it. + + Device does not support Wi-Fi Direct + Hotspot failed to start: error %s + Hotspot failed to start with an unknown error, reason %d + Hotspot failed to start: no group info + Error starting web server! + Warning: This is a debug app that can NOT be installed on another device + Manual To download the app on another phone, please connect to this Wi-Fi network: Network name (SSID) @@ -710,6 +724,16 @@ Instead of typing the address manually, you can also scan a QR code. To download the app on another phone, please scan this QR code to connect to this Wi-Fi network: After you are connected to the Wi-Fi, scan this QR code to download the app. + + + Download %s + Someone nearby shared %s with you. + After the download is complete, open the downloaded file and install it. + Troubleshooting + If you cannot download the app, try it with a different web browser app. + To install the downloaded app, you might need to allow installation of apps from \"Unknown sources\" in system settings. Afterwards, you may need to download the app again. + To install the downloaded app, you might need to allow your browser to install unknown apps. + Problems with connecting to Wi-Fi: Try disabling and re-enabling Wi-Fi on both phones and try again. If your phone complains that the Wi-Fi has no internet, tell it that you want to stay connected anyway. diff --git a/briar-android/witness.gradle b/briar-android/witness.gradle index 6b11c5cdc..4ae036ef9 100644 --- a/briar-android/witness.gradle +++ b/briar-android/witness.gradle @@ -216,6 +216,7 @@ dependencyVerification { 'org.jmock:jmock:2.8.2:jmock-2.8.2.jar:6c73cb4a2e6dbfb61fd99c9a768539c170ab6568e57846bd60dbf19596b65b16', 'org.jvnet.staxex:stax-ex:1.8:stax-ex-1.8.jar:95b05d9590af4154c6513b9c5dc1fb2e55b539972ba0a9ef28e9a0c01d83ad77', 'org.mockito:mockito-core:3.1.0:mockito-core-3.1.0.jar:89b09e518e04f5c35f5ccf7abe45e72f594070a53d95cc2579001bd392c5afa6', + 'org.nanohttpd:nanohttpd:2.3.1:nanohttpd-2.3.1.jar:de864c47818157141a24c9acb36df0c47d7bf15b7ff48c90610f3eb4e5df0e58', 'org.objenesis:objenesis:2.6:objenesis-2.6.jar:5e168368fbc250af3c79aa5fef0c3467a2d64e5a7bd74005f25d8399aeb0708d', 'org.ow2.asm:asm-analysis:7.0:asm-analysis-7.0.jar:e981f8f650c4d900bb033650b18e122fa6b161eadd5f88978d08751f72ee8474', 'org.ow2.asm:asm-commons:7.0:asm-commons-7.0.jar:fed348ef05958e3e846a3ac074a12af5f7936ef3d21ce44a62c4fa08a771927d',