diff --git a/bramble-api/src/main/java/org/briarproject/bramble/api/FeatureFlags.java b/bramble-api/src/main/java/org/briarproject/bramble/api/FeatureFlags.java
index 407e9717f..1f818e59a 100644
--- a/bramble-api/src/main/java/org/briarproject/bramble/api/FeatureFlags.java
+++ b/bramble-api/src/main/java/org/briarproject/bramble/api/FeatureFlags.java
@@ -12,4 +12,6 @@ public interface FeatureFlags {
boolean shouldEnableDisappearingMessages();
boolean shouldEnableConnectViaBluetooth();
+
+ boolean shouldEnableShareAppViaOfflineHotspot();
}
diff --git a/bramble-core/src/test/java/org/briarproject/bramble/test/BrambleCoreIntegrationTestModule.java b/bramble-core/src/test/java/org/briarproject/bramble/test/BrambleCoreIntegrationTestModule.java
index 22dea21f6..cac2d562b 100644
--- a/bramble-core/src/test/java/org/briarproject/bramble/test/BrambleCoreIntegrationTestModule.java
+++ b/bramble-core/src/test/java/org/briarproject/bramble/test/BrambleCoreIntegrationTestModule.java
@@ -43,6 +43,11 @@ public class BrambleCoreIntegrationTestModule {
public boolean shouldEnableConnectViaBluetooth() {
return true;
}
+
+ @Override
+ public boolean shouldEnableShareAppViaOfflineHotspot() {
+ return true;
+ }
};
}
}
diff --git a/briar-android/build.gradle b/briar-android/build.gradle
index e57296598..631cc7c03 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:$dagger_version"
annotationProcessor "com.github.bumptech.glide:compiler:$glideVersion"
diff --git a/briar-android/src/main/AndroidManifest.xml b/briar-android/src/main/AndroidManifest.xml
index 1eac6796d..4de40296e 100644
--- a/briar-android/src/main/AndroidManifest.xml
+++ b/briar-android/src/main/AndroidManifest.xml
@@ -18,6 +18,7 @@
+
@@ -441,6 +442,11 @@
android:label="@string/pending_contact_requests"
android:theme="@style/BriarTheme" />
+
+
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
+
+
If you can't download the app, try it with a different web
+ browser app.
+
+
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.
+
+ * 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.
+ */
+@NotNullByDefault
+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(@Nullable 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/FallbackFragment.java b/briar-android/src/main/java/org/briarproject/briar/android/hotspot/FallbackFragment.java
new file mode 100644
index 000000000..7b8d3702d
--- /dev/null
+++ b/briar-android/src/main/java/org/briarproject/briar/android/hotspot/FallbackFragment.java
@@ -0,0 +1,130 @@
+package org.briarproject.briar.android.hotspot;
+
+import android.content.Context;
+import android.content.Intent;
+import android.content.pm.ResolveInfo;
+import android.net.Uri;
+import android.os.Bundle;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.Button;
+import android.widget.ProgressBar;
+
+import org.briarproject.bramble.api.nullsafety.MethodsNotNullByDefault;
+import org.briarproject.bramble.api.nullsafety.ParametersNotNullByDefault;
+import org.briarproject.briar.R;
+import org.briarproject.briar.android.fragment.BaseFragment;
+
+import java.util.List;
+
+import javax.inject.Inject;
+
+import androidx.activity.result.ActivityResultLauncher;
+import androidx.annotation.Nullable;
+import androidx.fragment.app.Fragment;
+import androidx.fragment.app.FragmentActivity;
+import androidx.lifecycle.ViewModelProvider;
+
+import static android.content.Intent.ACTION_SEND;
+import static android.content.Intent.EXTRA_STREAM;
+import static android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION;
+import static android.content.pm.PackageManager.MATCH_DEFAULT_ONLY;
+import static android.os.Build.VERSION.SDK_INT;
+import static android.view.View.INVISIBLE;
+import static android.view.View.VISIBLE;
+import static androidx.activity.result.contract.ActivityResultContracts.CreateDocument;
+import static androidx.transition.TransitionManager.beginDelayedTransition;
+import static org.briarproject.briar.android.AppModule.getAndroidComponent;
+import static org.briarproject.briar.android.hotspot.HotspotViewModel.getApkFileName;
+
+
+@MethodsNotNullByDefault
+@ParametersNotNullByDefault
+public class FallbackFragment extends BaseFragment {
+
+ public static final String TAG = FallbackFragment.class.getName();
+
+ @Inject
+ ViewModelProvider.Factory viewModelFactory;
+
+ private HotspotViewModel viewModel;
+ private final ActivityResultLauncher launcher =
+ registerForActivityResult(new CreateDocument(),
+ this::onDocumentCreated);
+ private Button fallbackButton;
+ private ProgressBar progressBar;
+
+ @Override
+ public String getUniqueTag() {
+ return TAG;
+ }
+
+ @Override
+ public void onAttach(Context context) {
+ super.onAttach(context);
+ FragmentActivity activity = requireActivity();
+ getAndroidComponent(activity).inject(this);
+ viewModel = new ViewModelProvider(activity, viewModelFactory)
+ .get(HotspotViewModel.class);
+ }
+
+ @Nullable
+ @Override
+ public View onCreateView(LayoutInflater inflater,
+ @Nullable ViewGroup container,
+ @Nullable Bundle savedInstanceState) {
+ return inflater
+ .inflate(R.layout.fragment_hotspot_save_apk, container, false);
+ }
+
+ @Override
+ public void onViewCreated(View v, @Nullable Bundle savedInstanceState) {
+ super.onViewCreated(v, savedInstanceState);
+
+ fallbackButton = v.findViewById(R.id.fallbackButton);
+ progressBar = v.findViewById(R.id.progressBar);
+ fallbackButton.setOnClickListener(view -> {
+ beginDelayedTransition((ViewGroup) v);
+ fallbackButton.setVisibility(INVISIBLE);
+ progressBar.setVisibility(VISIBLE);
+
+ if (SDK_INT >= 19) launcher.launch(getApkFileName());
+ else viewModel.exportApk();
+ });
+ viewModel.getSavedApkToUri()
+ .observeEvent(this, uri -> shareUri(this, uri));
+ }
+
+ private void onDocumentCreated(@Nullable Uri uri) {
+ showButton();
+ if (uri != null) viewModel.exportApk(uri);
+ }
+
+ private void showButton() {
+ beginDelayedTransition((ViewGroup) requireView());
+ fallbackButton.setVisibility(VISIBLE);
+ progressBar.setVisibility(INVISIBLE);
+ }
+
+ static void shareUri(Fragment fragment, Uri uri) {
+ Intent i = new Intent(ACTION_SEND);
+ i.putExtra(EXTRA_STREAM, uri);
+ i.setType("*/*"); // gives us all sharing options
+ i.addFlags(FLAG_GRANT_READ_URI_PERMISSION);
+ Context ctx = fragment.requireContext();
+ if (SDK_INT <= 19) {
+ // Workaround for Android bug:
+ // ctx.grantUriPermission also needed for Android 4
+ List resInfoList = ctx.getPackageManager()
+ .queryIntentActivities(i, MATCH_DEFAULT_ONLY);
+ for (ResolveInfo resolveInfo : resInfoList) {
+ String packageName = resolveInfo.activityInfo.packageName;
+ ctx.grantUriPermission(packageName, uri,
+ FLAG_GRANT_READ_URI_PERMISSION);
+ }
+ }
+ fragment.startActivity(Intent.createChooser(i, null));
+ }
+
+}
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
new file mode 100644
index 000000000..1dbda3ce6
--- /dev/null
+++ b/briar-android/src/main/java/org/briarproject/briar/android/hotspot/HotspotActivity.java
@@ -0,0 +1,142 @@
+package org.briarproject.briar.android.hotspot;
+
+import android.content.Intent;
+import android.os.Bundle;
+import android.view.MenuItem;
+
+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.fragment.BaseFragment.BaseFragmentListener;
+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.Fragment;
+import androidx.fragment.app.FragmentManager;
+import androidx.lifecycle.ViewModelProvider;
+
+import static org.briarproject.briar.android.util.UiUtils.showFragment;
+import static org.briarproject.briar.api.android.AndroidNotificationManager.ACTION_STOP_HOTSPOT;
+
+@MethodsNotNullByDefault
+@ParametersNotNullByDefault
+public class HotspotActivity extends BriarActivity
+ implements BaseFragmentListener {
+
+ @Inject
+ ViewModelProvider.Factory viewModelFactory;
+
+ private HotspotViewModel viewModel;
+
+ @Override
+ public void injectActivity(ActivityComponent component) {
+ component.inject(this);
+ viewModel = new ViewModelProvider(this, viewModelFactory)
+ .get(HotspotViewModel.class);
+ }
+
+ @Override
+ public void onCreate(@Nullable Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ setContentView(R.layout.activity_fragment_container);
+
+ ActionBar ab = getSupportActionBar();
+ if (ab != null) {
+ ab.setDisplayHomeAsUpEnabled(true);
+ }
+
+ FragmentManager fm = getSupportFragmentManager();
+ viewModel.getState().observe(this, hotspotState -> {
+ if (hotspotState instanceof HotspotStarted) {
+ HotspotStarted started = (HotspotStarted) hotspotState;
+ String tag = HotspotFragment.TAG;
+ // check if fragment is already added
+ // to not lose state on configuration changes
+ if (fm.findFragmentByTag(tag) == null) {
+ if (!started.consume()) {
+ showFragment(fm, new HotspotFragment(), tag);
+ }
+ }
+ } else if (hotspotState instanceof HotspotError) {
+ HotspotError error = ((HotspotError) hotspotState);
+ showErrorFragment(error.getError());
+ }
+ });
+
+ if (savedInstanceState == null) {
+ // If there is no saved instance state, just start with the intro fragment.
+ fm.beginTransaction()
+ .replace(R.id.fragmentContainer, new HotspotIntroFragment(),
+ HotspotIntroFragment.TAG)
+ .commit();
+ } else if (viewModel.getState().getValue() == null) {
+ // If there is saved instance state, then there's either been an
+ // configuration change like rotated device or the activity has been
+ // destroyed and is now being re-created.
+ // In the latter case, the view model will have been destroyed, too.
+ // The activity can only have been destroyed if the user navigated
+ // away from the HotspotActivity which is nothing we
+ // intend to support, so we want to detect that and start from scratch
+ // in this case. We need to clean up existing fragments in order not
+ // to stack new fragments on top of old ones.
+
+ // If it is a configuration change and we moved past the intro
+ // fragment already, then the view model state will be != null,
+ // hence we can use this check for null to determine the destroyed
+ // activity. It can also be null if the user has not pressed
+ // "start sharing" yet, but in that case it won't harm to start from
+ // scratch.
+
+ Fragment current = fm.findFragmentById(R.id.fragmentContainer);
+ if (current instanceof HotspotIntroFragment) {
+ // If the currently displayed fragment is the intro fragment,
+ // there's nothing we need to do.
+ return;
+ }
+
+ // Remove everything from the back stack.
+ fm.popBackStackImmediate(null,
+ FragmentManager.POP_BACK_STACK_INCLUSIVE);
+
+ // Start fresh with the intro fragment.
+ fm.beginTransaction()
+ .replace(R.id.fragmentContainer, new HotspotIntroFragment(),
+ HotspotIntroFragment.TAG)
+ .commit();
+ }
+ }
+
+ private void showErrorFragment(String error) {
+ FragmentManager fm = getSupportFragmentManager();
+ String tag = HotspotErrorFragment.TAG;
+ if (fm.findFragmentByTag(tag) == null) {
+ Fragment f = HotspotErrorFragment.newInstance(error);
+ showFragment(fm, f, tag, false);
+ }
+ }
+
+ @Override
+ public boolean onOptionsItemSelected(MenuItem item) {
+ if (item.getItemId() == android.R.id.home) {
+ onBackPressed();
+ return true;
+ }
+ return super.onOptionsItemSelected(item);
+ }
+
+ @Override
+ protected void onNewIntent(Intent intent) {
+ super.onNewIntent(intent);
+ if (ACTION_STOP_HOTSPOT.equals(intent.getAction())) {
+ // also closes hotspot
+ supportFinishAfterTransition();
+ }
+ }
+
+}
diff --git a/briar-android/src/main/java/org/briarproject/briar/android/hotspot/HotspotErrorFragment.java b/briar-android/src/main/java/org/briarproject/briar/android/hotspot/HotspotErrorFragment.java
new file mode 100644
index 000000000..eb0b73165
--- /dev/null
+++ b/briar-android/src/main/java/org/briarproject/briar/android/hotspot/HotspotErrorFragment.java
@@ -0,0 +1,78 @@
+package org.briarproject.briar.android.hotspot;
+
+import android.os.Bundle;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.Button;
+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.fragment.BaseFragment;
+
+import javax.inject.Inject;
+
+import androidx.annotation.Nullable;
+import androidx.lifecycle.ViewModelProvider;
+
+import static org.briarproject.briar.android.util.UiUtils.triggerFeedback;
+
+
+@MethodsNotNullByDefault
+@ParametersNotNullByDefault
+public class HotspotErrorFragment extends BaseFragment {
+
+ public static final String TAG = HotspotErrorFragment.class.getName();
+
+ @Inject
+ ViewModelProvider.Factory viewModelFactory;
+
+ private static final String ERROR_MSG = "errorMessage";
+
+ public static HotspotErrorFragment newInstance(String message) {
+ HotspotErrorFragment f = new HotspotErrorFragment();
+ Bundle args = new Bundle();
+ args.putString(ERROR_MSG, message);
+ f.setArguments(args);
+ return f;
+ }
+
+ private String errorMessage;
+
+ @Override
+ public String getUniqueTag() {
+ return TAG;
+ }
+
+ @Override
+ public void onCreate(@Nullable Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+
+ Bundle args = requireArguments();
+ errorMessage = args.getString(ERROR_MSG);
+ }
+
+ @Nullable
+ @Override
+ public View onCreateView(LayoutInflater inflater,
+ @Nullable ViewGroup container,
+ @Nullable Bundle savedInstanceState) {
+ requireActivity().setTitle(R.string.error);
+ return inflater
+ .inflate(R.layout.fragment_hotspot_error, container, false);
+ }
+
+ @Override
+ public void onViewCreated(View v, @Nullable Bundle savedInstanceState) {
+ super.onViewCreated(v, savedInstanceState);
+ TextView msg = v.findViewById(R.id.errorMessageDetail);
+ msg.setText(errorMessage);
+
+ Button feedbackButton = v.findViewById(R.id.feedbackButton);
+ feedbackButton.setOnClickListener(
+ button -> triggerFeedback(requireContext(), errorMessage));
+ }
+
+}
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
new file mode 100644
index 000000000..94323b0d4
--- /dev/null
+++ b/briar-android/src/main/java/org/briarproject/briar/android/hotspot/HotspotFragment.java
@@ -0,0 +1,59 @@
+package org.briarproject.briar.android.hotspot;
+
+import android.os.Bundle;
+import android.view.View;
+
+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 org.briarproject.briar.android.util.BriarSnackbarBuilder;
+
+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 {
+
+ public final static String TAG = HotspotFragment.class.getName();
+
+ @Override
+ public void onViewCreated(View view, @Nullable Bundle savedInstanceState) {
+ super.onViewCreated(view, savedInstanceState);
+ connectedButton.setOnClickListener(v -> showNextFragment());
+ viewModel.getPeerConnectedEvent().observeEvent(getViewLifecycleOwner(),
+ this::onPeerConnected);
+ }
+
+ @Override
+ protected Fragment getFirstFragment() {
+ return ManualHotspotFragment.newInstance(true);
+ }
+
+ @Override
+ protected Fragment getSecondFragment() {
+ return QrHotspotFragment.newInstance(true);
+ }
+
+ private void onPeerConnected(boolean connected) {
+ if (!connected) return;
+ new BriarSnackbarBuilder()
+ .setAction(R.string.hotspot_peer_connected_action, v ->
+ showNextFragment())
+ .make(connectedButton, R.string.hotspot_peer_connected,
+ Snackbar.LENGTH_LONG)
+ .setAnchorView(connectedButton)
+ .show();
+ }
+
+ private void showNextFragment() {
+ 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/HotspotHelpFragment.java b/briar-android/src/main/java/org/briarproject/briar/android/hotspot/HotspotHelpFragment.java
new file mode 100644
index 000000000..aef52c5ac
--- /dev/null
+++ b/briar-android/src/main/java/org/briarproject/briar/android/hotspot/HotspotHelpFragment.java
@@ -0,0 +1,29 @@
+package org.briarproject.briar.android.hotspot;
+
+import android.os.Bundle;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+
+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;
+
+@MethodsNotNullByDefault
+@ParametersNotNullByDefault
+public class HotspotHelpFragment extends Fragment {
+
+ public final static String TAG = HotspotHelpFragment.class.getName();
+
+ @Override
+ public View onCreateView(LayoutInflater inflater,
+ @Nullable ViewGroup container,
+ @Nullable Bundle savedInstanceState) {
+ return inflater
+ .inflate(R.layout.fragment_hotspot_help, container, false);
+ }
+
+}
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
new file mode 100644
index 000000000..7979cb956
--- /dev/null
+++ b/briar-android/src/main/java/org/briarproject/briar/android/hotspot/HotspotIntroFragment.java
@@ -0,0 +1,130 @@
+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;
+import android.view.ViewGroup;
+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
+@ParametersNotNullByDefault
+public class HotspotIntroFragment extends Fragment {
+
+ public final static String TAG = HotspotIntroFragment.class.getName();
+
+ @Inject
+ 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);
+ FragmentActivity activity = requireActivity();
+ getAndroidComponent(activity).inject(this);
+ viewModel = new ViewModelProvider(activity, viewModelFactory)
+ .get(HotspotViewModel.class);
+ conditionManager =
+ new ConditionManager(activity, locationRequest, wifiRequest);
+ }
+
+ @Override
+ public View onCreateView(LayoutInflater inflater,
+ @Nullable ViewGroup container,
+ @Nullable Bundle savedInstanceState) {
+ View v = inflater
+ .inflate(R.layout.fragment_hotspot_intro, container, false);
+
+ startButton = v.findViewById(R.id.startButton);
+ progressBar = v.findViewById(R.id.progressBar);
+ progressTextView = v.findViewById(R.id.progressTextView);
+
+ startButton.setOnClickListener(button -> {
+ startButton.setEnabled(false);
+ conditionManager.startConditionChecks();
+ });
+
+ return v;
+ }
+
+ @Override
+ public void onStart() {
+ super.onStart();
+ conditionManager.resetPermissions();
+ }
+
+ private void startHotspot() {
+ startButton.setEnabled(true);
+ 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..aa6f67a9b
--- /dev/null
+++ b/briar-android/src/main/java/org/briarproject/briar/android/hotspot/HotspotManager.java
@@ -0,0 +1,396 @@
+package org.briarproject.briar.android.hotspot;
+
+import android.app.Application;
+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.db.DatabaseExecutor;
+import org.briarproject.bramble.api.db.DbException;
+import org.briarproject.bramble.api.lifecycle.IoExecutor;
+import org.briarproject.bramble.api.nullsafety.MethodsNotNullByDefault;
+import org.briarproject.bramble.api.nullsafety.ParametersNotNullByDefault;
+import org.briarproject.bramble.api.settings.Settings;
+import org.briarproject.bramble.api.settings.SettingsManager;
+import org.briarproject.bramble.api.system.AndroidExecutor;
+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 javax.inject.Inject;
+
+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;
+import static org.briarproject.briar.android.util.UiUtils.handleException;
+
+@MethodsNotNullByDefault
+@ParametersNotNullByDefault
+class HotspotManager implements ActionListener {
+
+ interface HotspotListener {
+ void onStartingHotspot();
+
+ @IoExecutor
+ void onHotspotStarted(NetworkConfig networkConfig);
+
+ @UiThread
+ void onDeviceConnected();
+
+ 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 static final String HOTSPOT_NAMESPACE = "hotspot";
+ private static final String HOTSPOT_KEY_SSID = "ssid";
+ private static final String HOTSPOT_KEY_PASS = "pass";
+
+ private final Context ctx;
+ @DatabaseExecutor
+ private final Executor dbExecutor;
+ @IoExecutor
+ private final Executor ioExecutor;
+ private final AndroidExecutor androidExecutor;
+ private final SettingsManager settingsManager;
+ private final SecureRandom random;
+ private final WifiManager wifiManager;
+ private final WifiP2pManager wifiP2pManager;
+ private final Handler handler;
+ private final String lockTag;
+
+ private HotspotListener listener;
+ private WifiManager.WifiLock wifiLock;
+ private WifiP2pManager.Channel channel;
+ @RequiresApi(29)
+ private volatile NetworkConfig savedNetworkConfig;
+
+ @Inject
+ HotspotManager(Application ctx,
+ @DatabaseExecutor Executor dbExecutor,
+ @IoExecutor Executor ioExecutor,
+ AndroidExecutor androidExecutor,
+ SettingsManager settingsManager,
+ SecureRandom random) {
+ this.ctx = ctx.getApplicationContext();
+ this.dbExecutor = dbExecutor;
+ this.ioExecutor = ioExecutor;
+ this.androidExecutor = androidExecutor;
+ this.settingsManager = settingsManager;
+ this.random = random;
+ 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";
+ }
+
+ void setHotspotListener(HotspotListener listener) {
+ this.listener = listener;
+ }
+
+ @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;
+ }
+ try {
+ if (SDK_INT >= 29) {
+ dbExecutor.execute(() -> {
+ // load savedNetworkConfig before starting hotspot
+ loadSavedNetworkConfig();
+ androidExecutor.runOnUiThread(() -> {
+ WifiP2pConfig config = new WifiP2pConfig.Builder()
+ .setGroupOperatingBand(GROUP_OWNER_BAND_2GHZ)
+ .setNetworkName(savedNetworkConfig.ssid)
+ .setPassphrase(savedNetworkConfig.password)
+ .build();
+ acquireLock();
+ wifiP2pManager.createGroup(channel, config, this);
+ });
+ });
+ } else {
+ acquireLock();
+ 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));
+ }
+ }
+
+ 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);
+ }
+ };
+ try {
+ if (channel == null) return;
+ wifiP2pManager.requestGroupInfo(channel, groupListener);
+ } catch (SecurityException e) {
+ // this should never happen, because we request permissions before
+ throw new AssertionError(e);
+ }
+ }
+
+ @UiThread
+ 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);
+ });
+ requestGroupInfoForConnection();
+ }
+
+ 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 (SDK_INT >= 29) {
+ // if we get here, the savedNetworkConfig must have a value
+ String networkName = savedNetworkConfig.ssid;
+ if (!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));
+ }
+ }
+
+ @UiThread
+ private void requestGroupInfoForConnection() {
+ if (LOG.isLoggable(INFO)) {
+ LOG.info("requestGroupInfo for connection");
+ }
+ GroupInfoListener groupListener = group -> {
+ if (group == null || group.getClientList().isEmpty()) {
+ handler.postDelayed(this::requestGroupInfoForConnection,
+ RETRY_DELAY_MILLIS);
+ } else {
+ if (LOG.isLoggable(INFO)) {
+ LOG.info("client list " + group.getClientList());
+ }
+ listener.onDeviceConnected();
+ }
+ };
+ try {
+ if (channel == null) return;
+ wifiP2pManager.requestGroupInfo(channel, groupListener);
+ } catch (SecurityException e) {
+ throw new AssertionError(e);
+ }
+ }
+
+ /**
+ * Store persistent Wi-Fi SSID and passphrase in Settings to improve UX
+ * so that users don't have to change them when attempting to connect.
+ * Works only on API 29 and above.
+ */
+ @RequiresApi(29)
+ @DatabaseExecutor
+ private void loadSavedNetworkConfig() {
+ try {
+ Settings settings = settingsManager.getSettings(HOTSPOT_NAMESPACE);
+ String ssid = settings.get(HOTSPOT_KEY_SSID);
+ String pass = settings.get(HOTSPOT_KEY_PASS);
+ if (ssid == null || pass == null) {
+ ssid = getSsid();
+ pass = getPassword();
+ settings.put(HOTSPOT_KEY_SSID, ssid);
+ settings.put(HOTSPOT_KEY_PASS, pass);
+ settingsManager.mergeSettings(settings, HOTSPOT_NAMESPACE);
+ }
+ savedNetworkConfig = new NetworkConfig(ssid, pass, null);
+ } catch (DbException e) {
+ handleException(ctx, androidExecutor, LOG, e);
+ // probably never happens, but if lets use non-persistent data
+ String ssid = getSsid();
+ String pass = getPassword();
+ savedNetworkConfig = new NetworkConfig(ssid, pass, null);
+ }
+ }
+
+ @RequiresApi(29)
+ private String getSsid() {
+ return "DIRECT-" + getRandomString(2) + "-" +
+ getRandomString(10);
+ }
+
+ @RequiresApi(29)
+ private String getPassword() {
+ return getRandomString(8);
+ }
+
+ 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/HotspotModule.java b/briar-android/src/main/java/org/briarproject/briar/android/hotspot/HotspotModule.java
new file mode 100644
index 000000000..ea7427b75
--- /dev/null
+++ b/briar-android/src/main/java/org/briarproject/briar/android/hotspot/HotspotModule.java
@@ -0,0 +1,18 @@
+package org.briarproject.briar.android.hotspot;
+
+import org.briarproject.briar.android.viewmodel.ViewModelKey;
+
+import androidx.lifecycle.ViewModel;
+import dagger.Binds;
+import dagger.Module;
+import dagger.multibindings.IntoMap;
+
+@Module
+public interface HotspotModule {
+
+ @Binds
+ @IntoMap
+ @ViewModelKey(HotspotViewModel.class)
+ ViewModel bindHotspotViewModel(HotspotViewModel hotspotViewModel);
+
+}
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..e37b0db2b
--- /dev/null
+++ b/briar-android/src/main/java/org/briarproject/briar/android/hotspot/HotspotState.java
@@ -0,0 +1,85 @@
+package org.briarproject.briar.android.hotspot;
+
+import android.graphics.Bitmap;
+
+import org.briarproject.bramble.api.nullsafety.NotNullByDefault;
+
+import androidx.annotation.Nullable;
+import androidx.annotation.UiThread;
+
+@NotNullByDefault
+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;
+ // 'consumed' is set to true once this state triggered a UI change, i.e.
+ // moving to the next fragment.
+ private boolean consumed = false;
+
+ HotspotStarted(NetworkConfig networkConfig,
+ WebsiteConfig websiteConfig) {
+ this.networkConfig = networkConfig;
+ this.websiteConfig = websiteConfig;
+ }
+
+ NetworkConfig getNetworkConfig() {
+ return networkConfig;
+ }
+
+ WebsiteConfig getWebsiteConfig() {
+ return websiteConfig;
+ }
+
+ /**
+ * Mark this state as consumed, i.e. the UI has already done something
+ * as a result of the state changing to this. This can be used in order
+ * to not repeat actions such as showing fragments on rotation changes.
+ */
+ @UiThread
+ boolean consume() {
+ boolean old = consumed;
+ consumed = true;
+ return old;
+ }
+ }
+
+ 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
new file mode 100644
index 000000000..7b923e1e0
--- /dev/null
+++ b/briar-android/src/main/java/org/briarproject/briar/android/hotspot/HotspotViewModel.java
@@ -0,0 +1,230 @@
+package org.briarproject.briar.android.hotspot;
+
+import android.app.Application;
+import android.net.Uri;
+
+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.android.viewmodel.LiveEvent;
+import org.briarproject.briar.android.viewmodel.MutableLiveEvent;
+import org.briarproject.briar.api.android.AndroidNotificationManager;
+
+import java.io.File;
+import java.io.FileInputStream;
+import java.io.FileNotFoundException;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.io.OutputStream;
+import java.util.concurrent.Executor;
+import java.util.logging.Logger;
+
+import javax.inject.Inject;
+
+import androidx.annotation.Nullable;
+import androidx.annotation.UiThread;
+import androidx.lifecycle.LiveData;
+import androidx.lifecycle.MutableLiveData;
+
+import static android.os.Build.VERSION.SDK_INT;
+import static android.os.Environment.DIRECTORY_DOWNLOADS;
+import static android.os.Environment.getExternalStoragePublicDirectory;
+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.IoUtils.copyAndClose;
+import static org.briarproject.briar.BuildConfig.DEBUG;
+import static org.briarproject.briar.BuildConfig.VERSION_NAME;
+
+@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<>();
+ private final MutableLiveEvent peerConnected =
+ new MutableLiveEvent<>();
+ private final MutableLiveEvent savedApkToUri =
+ new MutableLiveEvent<>();
+
+ @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 app,
+ @DatabaseExecutor Executor dbExecutor,
+ LifecycleManager lifecycleManager,
+ TransactionManager db,
+ AndroidExecutor androidExecutor,
+ @IoExecutor Executor ioExecutor,
+ HotspotManager hotspotManager,
+ WebServerManager webServerManager,
+ AndroidNotificationManager notificationManager) {
+ super(app, dbExecutor, lifecycleManager, db, androidExecutor);
+ this.ioExecutor = ioExecutor;
+ this.notificationManager = notificationManager;
+ this.hotspotManager = hotspotManager;
+ this.hotspotManager.setHotspotListener(this);
+ this.webServerManager = webServerManager;
+ this.webServerManager.setListener(this);
+ }
+
+ @UiThread
+ void startHotspot() {
+ HotspotState s = state.getValue();
+ if (s instanceof HotspotStarted) {
+ // This can happen if the user navigates back to intro fragment and
+ // taps 'start sharing' again. In this case, don't try to start the
+ // hotspot again. Instead, just create a new, unconsumed HotspotStarted
+ // event with the same config.
+ HotspotStarted old = (HotspotStarted) s;
+ state.setValue(new HotspotStarted(old.getNetworkConfig(),
+ old.getWebsiteConfig()));
+ } else {
+ hotspotManager.startWifiP2pHotspot();
+ notificationManager.showHotspotNotification();
+ }
+ }
+
+ @UiThread
+ private void stopHotspot() {
+ ioExecutor.execute(webServerManager::stopWebServer);
+ hotspotManager.stopWifiP2pHotspot();
+ notificationManager.clearHotspotNotification();
+ }
+
+ @Override
+ protected void onCleared() {
+ super.onCleared();
+ stopHotspot();
+ }
+
+ @Override
+ public void onStartingHotspot() {
+ state.setValue(new StartingHotspot());
+ }
+
+ @Override
+ @IoExecutor
+ public void onHotspotStarted(NetworkConfig networkConfig) {
+ this.networkConfig = networkConfig;
+ LOG.info("starting webserver");
+ webServerManager.startWebServer();
+ }
+
+ @UiThread
+ @Override
+ public void onDeviceConnected() {
+ peerConnected.setEvent(true);
+ }
+
+ @Override
+ public void onHotspotStopped() {
+ LOG.info("stopping webserver");
+ ioExecutor.execute(webServerManager::stopWebServer);
+ }
+
+ @Override
+ public void onHotspotError(String error) {
+ if (LOG.isLoggable(WARNING)) {
+ LOG.warning("Hotspot error: " + error);
+ }
+ state.postValue(new HotspotError(error));
+ ioExecutor.execute(webServerManager::stopWebServer);
+ notificationManager.clearHotspotNotification();
+ }
+
+ @Override
+ @IoExecutor
+ public void onWebServerStarted(WebsiteConfig websiteConfig) {
+ NetworkConfig nc = requireNonNull(networkConfig);
+ state.postValue(new HotspotStarted(nc, websiteConfig));
+ networkConfig = null;
+ }
+
+ @Override
+ @IoExecutor
+ public void onWebServerError() {
+ state.postValue(new HotspotError(getApplication()
+ .getString(R.string.hotspot_error_web_server_start)));
+ hotspotManager.stopWifiP2pHotspot();
+ }
+
+ void exportApk(Uri uri) {
+ if (SDK_INT < 19) throw new IllegalStateException();
+ try {
+ OutputStream out = getApplication().getContentResolver()
+ .openOutputStream(uri, "wt");
+ writeApk(out, uri);
+ } catch (FileNotFoundException e) {
+ handleException(e);
+ }
+ }
+
+ void exportApk() {
+ if (SDK_INT >= 19) throw new IllegalStateException();
+ File path = getExternalStoragePublicDirectory(DIRECTORY_DOWNLOADS);
+ //noinspection ResultOfMethodCallIgnored
+ path.mkdirs();
+ File file = new File(path, getApkFileName());
+ try {
+ OutputStream out = new FileOutputStream(file);
+ writeApk(out, Uri.fromFile(file));
+ } catch (FileNotFoundException e) {
+ handleException(e);
+ }
+ }
+
+ static String getApkFileName() {
+ return "briar" + (DEBUG ? "-debug-" : "-") + VERSION_NAME + ".apk";
+ }
+
+ private void writeApk(OutputStream out, Uri uriToShare) {
+ File apk = new File(getApplication().getPackageCodePath());
+ ioExecutor.execute(() -> {
+ try {
+ FileInputStream in = new FileInputStream(apk);
+ copyAndClose(in, out);
+ savedApkToUri.postEvent(uriToShare);
+ } catch (IOException e) {
+ handleException(e);
+ }
+ });
+ }
+
+ LiveData getState() {
+ return state;
+ }
+
+ LiveEvent getPeerConnectedEvent() {
+ return peerConnected;
+ }
+
+ LiveEvent getSavedApkToUri() {
+ return savedApkToUri;
+ }
+
+}
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
new file mode 100644
index 000000000..f049eab18
--- /dev/null
+++ b/briar-android/src/main/java/org/briarproject/briar/android/hotspot/ManualHotspotFragment.java
@@ -0,0 +1,95 @@
+package org.briarproject.briar.android.hotspot;
+
+import android.content.Context;
+import android.os.Bundle;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.TextView;
+
+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.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
+public class ManualHotspotFragment extends Fragment {
+
+ public final static String TAG = ManualHotspotFragment.class.getName();
+
+ @Inject
+ ViewModelProvider.Factory viewModelFactory;
+
+ private HotspotViewModel viewModel;
+
+ static ManualHotspotFragment newInstance(boolean forWifiConnect) {
+ ManualHotspotFragment f = new ManualHotspotFragment();
+ Bundle bundle = new Bundle();
+ bundle.putBoolean(ARG_FOR_WIFI_CONNECT, forWifiConnect);
+ f.setArguments(bundle);
+ return f;
+ }
+
+ @Override
+ public void onAttach(Context context) {
+ super.onAttach(context);
+ getAndroidComponent(requireContext()).inject(this);
+ viewModel = new ViewModelProvider(requireActivity(), viewModelFactory)
+ .get(HotspotViewModel.class);
+ }
+
+ @Override
+ public View onCreateView(LayoutInflater inflater,
+ @Nullable ViewGroup container,
+ @Nullable Bundle savedInstanceState) {
+ return inflater
+ .inflate(R.layout.fragment_hotspot_manual, container, false);
+ }
+
+ @Override
+ public void onViewCreated(View v, @Nullable Bundle savedInstanceState) {
+ super.onViewCreated(v, savedInstanceState);
+
+ TextView manualIntroView = v.findViewById(R.id.manualIntroView);
+ TextView ssidLabelView = v.findViewById(R.id.ssidLabelView);
+ TextView ssidView = v.findViewById(R.id.ssidView);
+ 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);
+ 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);
+ 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
new file mode 100644
index 000000000..f56f51580
--- /dev/null
+++ b/briar-android/src/main/java/org/briarproject/briar/android/hotspot/QrHotspotFragment.java
@@ -0,0 +1,81 @@
+package org.briarproject.briar.android.hotspot;
+
+import android.content.Context;
+import android.os.Bundle;
+import android.view.LayoutInflater;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.ImageView;
+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;
+
+import static org.briarproject.briar.android.AppModule.getAndroidComponent;
+import static org.briarproject.briar.android.hotspot.AbstractTabsFragment.ARG_FOR_WIFI_CONNECT;
+
+@MethodsNotNullByDefault
+@ParametersNotNullByDefault
+public class QrHotspotFragment extends Fragment {
+
+ public final static String TAG = QrHotspotFragment.class.getName();
+
+ @Inject
+ ViewModelProvider.Factory viewModelFactory;
+
+ private HotspotViewModel viewModel;
+
+ static QrHotspotFragment newInstance(boolean forWifiConnect) {
+ QrHotspotFragment f = new QrHotspotFragment();
+ Bundle bundle = new Bundle();
+ bundle.putBoolean(ARG_FOR_WIFI_CONNECT, forWifiConnect);
+ f.setArguments(bundle);
+ return f;
+ }
+
+ @Override
+ public void onAttach(Context context) {
+ super.onAttach(context);
+ getAndroidComponent(requireContext()).inject(this);
+ viewModel = new ViewModelProvider(requireActivity(), viewModelFactory)
+ .get(HotspotViewModel.class);
+ }
+
+ @Override
+ public View onCreateView(LayoutInflater inflater,
+ @Nullable ViewGroup container,
+ @Nullable Bundle savedInstanceState) {
+ View v = inflater
+ .inflate(R.layout.fragment_hotspot_qr, container, false);
+
+ 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);
+ consumer = state ->
+ qrCodeView.setImageBitmap(state.getNetworkConfig().qrCode);
+ } else {
+ qrIntroView.setText(R.string.hotspot_qr_site);
+ 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..b9e9b6133
--- /dev/null
+++ b/briar-android/src/main/java/org/briarproject/briar/android/hotspot/WebServer.java
@@ -0,0 +1,135 @@
+package org.briarproject.briar.android.hotspot;
+
+import android.content.Context;
+
+import org.briarproject.bramble.api.nullsafety.NotNullByDefault;
+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;
+import static org.briarproject.briar.android.hotspot.HotspotViewModel.getApkFileName;
+
+@NotNullByDefault
+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,
+ ctx.getString(R.string.hotspot_error_web_server_serve));
+ }
+ 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;
+ String filename = getApkFileName();
+ 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(".button").first().attr("href", filename);
+ 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(@Nullable 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,
+ ctx.getString(R.string.hotspot_error_web_server_serve));
+ }
+ 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..8e5d70bd4
--- /dev/null
+++ b/briar-android/src/main/java/org/briarproject/briar/android/hotspot/WebServerManager.java
@@ -0,0 +1,125 @@
+package org.briarproject.briar.android.hotspot;
+
+import android.app.Application;
+import android.graphics.Bitmap;
+import android.util.DisplayMetrics;
+
+import org.briarproject.bramble.api.lifecycle.IoExecutor;
+import org.briarproject.bramble.api.nullsafety.MethodsNotNullByDefault;
+import org.briarproject.bramble.api.nullsafety.ParametersNotNullByDefault;
+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 javax.inject.Inject;
+
+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;
+
+@MethodsNotNullByDefault
+@ParametersNotNullByDefault
+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 DisplayMetrics dm;
+
+ private WebServerListener listener;
+
+ @Inject
+ WebServerManager(Application ctx) {
+ webServer = new WebServer(ctx);
+ dm = ctx.getResources().getDisplayMetrics();
+ }
+
+ void setListener(WebServerListener listener) {
+ this.listener = listener;
+ }
+
+ @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/hotspot/WebsiteFragment.java b/briar-android/src/main/java/org/briarproject/briar/android/hotspot/WebsiteFragment.java
new file mode 100644
index 000000000..6ccf4d9a7
--- /dev/null
+++ b/briar-android/src/main/java/org/briarproject/briar/android/hotspot/WebsiteFragment.java
@@ -0,0 +1,36 @@
+package org.briarproject.briar.android.hotspot;
+
+import android.os.Bundle;
+import android.view.View;
+
+import org.briarproject.bramble.api.nullsafety.MethodsNotNullByDefault;
+import org.briarproject.bramble.api.nullsafety.ParametersNotNullByDefault;
+
+import androidx.annotation.Nullable;
+import androidx.fragment.app.Fragment;
+
+import static android.view.View.GONE;
+
+@MethodsNotNullByDefault
+@ParametersNotNullByDefault
+public class WebsiteFragment extends AbstractTabsFragment {
+
+ public final static String TAG = WebsiteFragment.class.getName();
+
+ @Override
+ public void onViewCreated(View view, @Nullable Bundle savedInstanceState) {
+ super.onViewCreated(view, savedInstanceState);
+ connectedButton.setVisibility(GONE);
+ }
+
+ @Override
+ protected Fragment getFirstFragment() {
+ return ManualHotspotFragment.newInstance(false);
+ }
+
+ @Override
+ protected Fragment getSecondFragment() {
+ return QrHotspotFragment.newInstance(false);
+ }
+
+}
diff --git a/briar-android/src/main/java/org/briarproject/briar/android/reporting/BriarExceptionHandler.java b/briar-android/src/main/java/org/briarproject/briar/android/reporting/BriarExceptionHandler.java
index 740ebbddd..320f6d431 100644
--- a/briar-android/src/main/java/org/briarproject/briar/android/reporting/BriarExceptionHandler.java
+++ b/briar-android/src/main/java/org/briarproject/briar/android/reporting/BriarExceptionHandler.java
@@ -34,7 +34,7 @@ class BriarExceptionHandler implements UncaughtExceptionHandler {
// activity runs in its own process, so we can kill the old one
startDevReportActivity(app.getApplicationContext(),
- CrashReportActivity.class, e, appStartTime, logKey);
+ CrashReportActivity.class, e, appStartTime, logKey, null);
Process.killProcess(Process.myPid());
System.exit(10);
}
diff --git a/briar-android/src/main/java/org/briarproject/briar/android/reporting/CrashReportActivity.java b/briar-android/src/main/java/org/briarproject/briar/android/reporting/CrashReportActivity.java
index e07b63d53..38dd7c545 100644
--- a/briar-android/src/main/java/org/briarproject/briar/android/reporting/CrashReportActivity.java
+++ b/briar-android/src/main/java/org/briarproject/briar/android/reporting/CrashReportActivity.java
@@ -33,6 +33,7 @@ import static java.util.Objects.requireNonNull;
public class CrashReportActivity extends BaseActivity
implements BaseFragmentListener {
+ public static final String EXTRA_INITIAL_COMMENT = "initialComment";
public static final String EXTRA_THROWABLE = "throwable";
public static final String EXTRA_APP_START_TIME = "appStartTime";
public static final String EXTRA_APP_LOGCAT = "logcat";
@@ -55,10 +56,11 @@ public class CrashReportActivity extends BaseActivity
setContentView(R.layout.activity_dev_report);
Intent intent = getIntent();
+ String initialComment = intent.getStringExtra(EXTRA_INITIAL_COMMENT);
Throwable t = (Throwable) intent.getSerializableExtra(EXTRA_THROWABLE);
long appStartTime = intent.getLongExtra(EXTRA_APP_START_TIME, -1);
byte[] logKey = intent.getByteArrayExtra(EXTRA_APP_LOGCAT);
- viewModel.init(t, appStartTime, logKey);
+ viewModel.init(t, appStartTime, logKey, initialComment);
viewModel.getShowReport().observeEvent(this, show -> {
if (show) displayFragment(true);
});
diff --git a/briar-android/src/main/java/org/briarproject/briar/android/reporting/ReportFormFragment.java b/briar-android/src/main/java/org/briarproject/briar/android/reporting/ReportFormFragment.java
index 2efd021e8..328ced375 100644
--- a/briar-android/src/main/java/org/briarproject/briar/android/reporting/ReportFormFragment.java
+++ b/briar-android/src/main/java/org/briarproject/briar/android/reporting/ReportFormFragment.java
@@ -78,6 +78,9 @@ public class ReportFormFragment extends BaseFragment {
list = v.findViewById(R.id.list);
progress = v.findViewById(R.id.progress_wheel);
+ if (viewModel.getInitialComment() != null)
+ userCommentView.setText(viewModel.getInitialComment());
+
if (viewModel.isFeedback()) {
includeDebugReport
.setText(getString(R.string.include_debug_report_feedback));
diff --git a/briar-android/src/main/java/org/briarproject/briar/android/reporting/ReportViewModel.java b/briar-android/src/main/java/org/briarproject/briar/android/reporting/ReportViewModel.java
index ef8679b7b..6e0a898b6 100644
--- a/briar-android/src/main/java/org/briarproject/briar/android/reporting/ReportViewModel.java
+++ b/briar-android/src/main/java/org/briarproject/briar/android/reporting/ReportViewModel.java
@@ -64,6 +64,8 @@ class ReportViewModel extends AndroidViewModel {
private final MutableLiveEvent closeReport =
new MutableLiveEvent<>();
private boolean isFeedback;
+ @Nullable
+ private String initialComment;
@Inject
ReportViewModel(@NonNull Application application,
@@ -80,7 +82,8 @@ class ReportViewModel extends AndroidViewModel {
}
void init(@Nullable Throwable t, long appStartTime,
- @Nullable byte[] logKey) {
+ @Nullable byte[] logKey, @Nullable String initialComment) {
+ this.initialComment = initialComment;
isFeedback = t == null;
if (reportData.getValue() == null) new SingleShotAndroidExecutor(() -> {
String decryptedLogs;
@@ -103,6 +106,11 @@ class ReportViewModel extends AndroidViewModel {
}).start();
}
+ @Nullable
+ String getInitialComment() {
+ return initialComment;
+ }
+
boolean isFeedback() {
return isFeedback;
}
@@ -140,7 +148,7 @@ class ReportViewModel extends AndroidViewModel {
/**
* The content of the report that will be loaded after
- * {@link #init(Throwable, long, byte[])} was called.
+ * {@link #init(Throwable, long, byte[], String)} was called.
*/
LiveData getReportData() {
return reportData;
diff --git a/briar-android/src/main/java/org/briarproject/briar/android/settings/SettingsFragment.java b/briar-android/src/main/java/org/briarproject/briar/android/settings/SettingsFragment.java
index e3769e9e2..591f9031c 100644
--- a/briar-android/src/main/java/org/briarproject/briar/android/settings/SettingsFragment.java
+++ b/briar-android/src/main/java/org/briarproject/briar/android/settings/SettingsFragment.java
@@ -38,6 +38,7 @@ public class SettingsFragment extends PreferenceFragmentCompat {
private static final String PREF_KEY_FEEDBACK = "pref_key_send_feedback";
private static final String PREF_KEY_DEV = "pref_key_dev";
private static final String PREF_KEY_EXPLODE = "pref_key_explode";
+ private static final String PREF_KEY_SHARE_APP = "pref_key_share_app";
@Inject
ViewModelProvider.Factory viewModelFactory;
@@ -84,6 +85,12 @@ public class SettingsFragment extends PreferenceFragmentCompat {
PreferenceGroup dev = requireNonNull(findPreference(PREF_KEY_DEV));
dev.setVisible(false);
}
+
+ if (!viewModel.shouldEnableShareAppViaOfflineHotspot()) {
+ Preference shareApp =
+ requireNonNull(findPreference(PREF_KEY_SHARE_APP));
+ shareApp.setVisible(false);
+ }
}
@Override
diff --git a/briar-android/src/main/java/org/briarproject/briar/android/settings/SettingsViewModel.java b/briar-android/src/main/java/org/briarproject/briar/android/settings/SettingsViewModel.java
index ba92042f0..f6aa3da2f 100644
--- a/briar-android/src/main/java/org/briarproject/briar/android/settings/SettingsViewModel.java
+++ b/briar-android/src/main/java/org/briarproject/briar/android/settings/SettingsViewModel.java
@@ -262,4 +262,8 @@ class SettingsViewModel extends DbViewModel implements EventListener {
return screenLockTimeout;
}
+ boolean shouldEnableShareAppViaOfflineHotspot() {
+ return featureFlags.shouldEnableShareAppViaOfflineHotspot();
+ }
+
}
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..66378bf22 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,10 @@ 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.fragment.app.FragmentTransaction;
import androidx.lifecycle.LifecycleOwner;
import androidx.lifecycle.LiveData;
import androidx.lifecycle.Observer;
@@ -115,6 +118,7 @@ import static org.briarproject.briar.BuildConfig.APPLICATION_ID;
import static org.briarproject.briar.android.TestingConstants.EXPIRY_DATE;
import static org.briarproject.briar.android.reporting.CrashReportActivity.EXTRA_APP_LOGCAT;
import static org.briarproject.briar.android.reporting.CrashReportActivity.EXTRA_APP_START_TIME;
+import static org.briarproject.briar.android.reporting.CrashReportActivity.EXTRA_INITIAL_COMMENT;
import static org.briarproject.briar.android.reporting.CrashReportActivity.EXTRA_THROWABLE;
@MethodsNotNullByDefault
@@ -139,6 +143,22 @@ public class UiUtils {
imm.hideSoftInputFromWindow(view.getWindowToken(), 0);
}
+ public static void showFragment(FragmentManager fm, Fragment f,
+ @Nullable String tag) {
+ showFragment(fm, f, tag, true);
+ }
+
+ public static void showFragment(FragmentManager fm, Fragment f,
+ @Nullable String tag, boolean addToBackStack) {
+ FragmentTransaction ta = 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);
+ if (addToBackStack) ta.addToBackStack(tag);
+ ta.commit();
+ }
+
public static String getContactDisplayName(Author author,
@Nullable String alias) {
String name = author.getName();
@@ -415,17 +435,25 @@ public class UiUtils {
}
public static void triggerFeedback(Context ctx) {
- startDevReportActivity(ctx, FeedbackActivity.class, null, null, null);
+ triggerFeedback(ctx, null);
+ }
+
+ public static void triggerFeedback(Context ctx,
+ @Nullable String initialComment) {
+ startDevReportActivity(ctx, FeedbackActivity.class, null, null, null,
+ initialComment);
}
public static void startDevReportActivity(Context ctx,
Class extends FragmentActivity> activity, @Nullable Throwable t,
- @Nullable Long appStartTime, @Nullable byte[] logKey) {
+ @Nullable Long appStartTime, @Nullable byte[] logKey, @Nullable
+ String initialComment) {
final Intent dialogIntent = new Intent(ctx, activity);
dialogIntent.setFlags(FLAG_ACTIVITY_NEW_TASK);
dialogIntent.putExtra(EXTRA_THROWABLE, t);
dialogIntent.putExtra(EXTRA_APP_START_TIME, appStartTime);
dialogIntent.putExtra(EXTRA_APP_LOGCAT, logKey);
+ dialogIntent.putExtra(EXTRA_INITIAL_COMMENT, initialComment);
ctx.startActivity(dialogIntent);
}
diff --git a/briar-android/src/main/java/org/briarproject/briar/api/android/AndroidNotificationManager.java b/briar-android/src/main/java/org/briarproject/briar/api/android/AndroidNotificationManager.java
index 6a3a5cbb2..1823ab7e1 100644
--- a/briar-android/src/main/java/org/briarproject/briar/api/android/AndroidNotificationManager.java
+++ b/briar-android/src/main/java/org/briarproject/briar/api/android/AndroidNotificationManager.java
@@ -31,6 +31,7 @@ public interface AndroidNotificationManager {
int FORUM_POST_NOTIFICATION_ID = 6;
int BLOG_POST_NOTIFICATION_ID = 7;
int CONTACT_ADDED_NOTIFICATION_ID = 8;
+ int HOTSPOT_NOTIFICATION_ID = 9;
// Channel IDs
String CONTACT_CHANNEL_ID = "contacts";
@@ -43,9 +44,11 @@ public interface AndroidNotificationManager {
String ONGOING_CHANNEL_ID = "zForegroundService2";
String FAILURE_CHANNEL_ID = "zStartupFailure";
String REMINDER_CHANNEL_ID = "zSignInReminder";
+ String HOTSPOT_CHANNEL_ID = "zHotspot";
// Actions for pending intents
String ACTION_DISMISS_REMINDER = "dismissReminder";
+ String ACTION_STOP_HOTSPOT = "stopHotspot";
Notification getForegroundNotification();
@@ -94,4 +97,8 @@ public interface AndroidNotificationManager {
void blockAllBlogPostNotifications();
void unblockAllBlogPostNotifications();
+
+ void showHotspotNotification();
+
+ void clearHotspotNotification();
}
diff --git a/briar-android/src/main/res/drawable-anydpi-v24/notification_hotspot.xml b/briar-android/src/main/res/drawable-anydpi-v24/notification_hotspot.xml
new file mode 100644
index 000000000..e1535c8e4
--- /dev/null
+++ b/briar-android/src/main/res/drawable-anydpi-v24/notification_hotspot.xml
@@ -0,0 +1,16 @@
+
+
+
+
+
diff --git a/briar-android/src/main/res/drawable-hdpi/notification_hotspot.png b/briar-android/src/main/res/drawable-hdpi/notification_hotspot.png
new file mode 100644
index 000000000..c46f4f0f0
Binary files /dev/null and b/briar-android/src/main/res/drawable-hdpi/notification_hotspot.png differ
diff --git a/briar-android/src/main/res/drawable-mdpi/notification_hotspot.png b/briar-android/src/main/res/drawable-mdpi/notification_hotspot.png
new file mode 100644
index 000000000..7b925f098
Binary files /dev/null and b/briar-android/src/main/res/drawable-mdpi/notification_hotspot.png differ
diff --git a/briar-android/src/main/res/drawable-xhdpi/notification_hotspot.png b/briar-android/src/main/res/drawable-xhdpi/notification_hotspot.png
new file mode 100644
index 000000000..59ce89496
Binary files /dev/null and b/briar-android/src/main/res/drawable-xhdpi/notification_hotspot.png differ
diff --git a/briar-android/src/main/res/drawable-xxhdpi/notification_hotspot.png b/briar-android/src/main/res/drawable-xxhdpi/notification_hotspot.png
new file mode 100644
index 000000000..57c39a2e3
Binary files /dev/null and b/briar-android/src/main/res/drawable-xxhdpi/notification_hotspot.png differ
diff --git a/briar-android/src/main/res/drawable/ic_circle_small.xml b/briar-android/src/main/res/drawable/ic_circle_small.xml
new file mode 100644
index 000000000..97a7c4689
--- /dev/null
+++ b/briar-android/src/main/res/drawable/ic_circle_small.xml
@@ -0,0 +1,10 @@
+
+
+
diff --git a/briar-android/src/main/res/drawable/ic_portable_wifi_off.xml b/briar-android/src/main/res/drawable/ic_portable_wifi_off.xml
new file mode 100644
index 000000000..d214583a2
--- /dev/null
+++ b/briar-android/src/main/res/drawable/ic_portable_wifi_off.xml
@@ -0,0 +1,10 @@
+
+
+
diff --git a/briar-android/src/main/res/drawable/ic_qr_code.xml b/briar-android/src/main/res/drawable/ic_qr_code.xml
new file mode 100644
index 000000000..55b5bb0a7
--- /dev/null
+++ b/briar-android/src/main/res/drawable/ic_qr_code.xml
@@ -0,0 +1,40 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/briar-android/src/main/res/drawable/ic_settings_share.xml b/briar-android/src/main/res/drawable/ic_settings_share.xml
new file mode 100644
index 000000000..79baee417
--- /dev/null
+++ b/briar-android/src/main/res/drawable/ic_settings_share.xml
@@ -0,0 +1,12 @@
+
+
+
diff --git a/briar-android/src/main/res/drawable/ic_wifi_tethering.xml b/briar-android/src/main/res/drawable/ic_wifi_tethering.xml
new file mode 100644
index 000000000..3a3a02798
--- /dev/null
+++ b/briar-android/src/main/res/drawable/ic_wifi_tethering.xml
@@ -0,0 +1,10 @@
+
+
+
diff --git a/briar-android/src/main/res/layout/fragment_error.xml b/briar-android/src/main/res/layout/fragment_error.xml
index bd663a9e3..ecb2bfe88 100644
--- a/briar-android/src/main/res/layout/fragment_error.xml
+++ b/briar-android/src/main/res/layout/fragment_error.xml
@@ -9,11 +9,7 @@
android:id="@+id/errorIcon"
android:layout_width="128dp"
android:layout_height="128dp"
- android:layout_marginStart="8dp"
- android:layout_marginLeft="8dp"
- android:layout_marginTop="8dp"
- android:layout_marginEnd="8dp"
- android:layout_marginRight="8dp"
+ android:layout_margin="8dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
@@ -25,11 +21,7 @@
android:id="@+id/errorTitle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
- android:layout_marginStart="8dp"
- android:layout_marginLeft="8dp"
- android:layout_marginTop="8dp"
- android:layout_marginEnd="8dp"
- android:layout_marginRight="8dp"
+ android:layout_margin="8dp"
android:text="@string/sorry"
android:textSize="@dimen/text_size_xlarge"
app:layout_constraintEnd_toEndOf="parent"
@@ -49,6 +41,6 @@
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/errorTitle"
- tools:text="@string/qr_code_unsupported" />
+ tools:text="@string/startup_failed_service_error" />
diff --git a/briar-android/src/main/res/layout/fragment_hotspot_error.xml b/briar-android/src/main/res/layout/fragment_hotspot_error.xml
new file mode 100644
index 000000000..2e02edf41
--- /dev/null
+++ b/briar-android/src/main/res/layout/fragment_hotspot_error.xml
@@ -0,0 +1,80 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/briar-android/src/main/res/layout/fragment_hotspot_help.xml b/briar-android/src/main/res/layout/fragment_hotspot_help.xml
new file mode 100644
index 000000000..13d2cd034
--- /dev/null
+++ b/briar-android/src/main/res/layout/fragment_hotspot_help.xml
@@ -0,0 +1,126 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/briar-android/src/main/res/layout/fragment_hotspot_intro.xml b/briar-android/src/main/res/layout/fragment_hotspot_intro.xml
new file mode 100644
index 000000000..43dbf1a9a
--- /dev/null
+++ b/briar-android/src/main/res/layout/fragment_hotspot_intro.xml
@@ -0,0 +1,86 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/briar-android/src/main/res/layout/fragment_hotspot_manual.xml b/briar-android/src/main/res/layout/fragment_hotspot_manual.xml
new file mode 100644
index 000000000..c35087421
--- /dev/null
+++ b/briar-android/src/main/res/layout/fragment_hotspot_manual.xml
@@ -0,0 +1,95 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/briar-android/src/main/res/layout/fragment_hotspot_qr.xml b/briar-android/src/main/res/layout/fragment_hotspot_qr.xml
new file mode 100644
index 000000000..d25de032d
--- /dev/null
+++ b/briar-android/src/main/res/layout/fragment_hotspot_qr.xml
@@ -0,0 +1,50 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/briar-android/src/main/res/layout/fragment_hotspot_save_apk.xml b/briar-android/src/main/res/layout/fragment_hotspot_save_apk.xml
new file mode 100644
index 000000000..ee3c637f1
--- /dev/null
+++ b/briar-android/src/main/res/layout/fragment_hotspot_save_apk.xml
@@ -0,0 +1,53 @@
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/briar-android/src/main/res/layout/fragment_hotspot_tabs.xml b/briar-android/src/main/res/layout/fragment_hotspot_tabs.xml
new file mode 100644
index 000000000..941f970b4
--- /dev/null
+++ b/briar-android/src/main/res/layout/fragment_hotspot_tabs.xml
@@ -0,0 +1,93 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/briar-android/src/main/res/menu/hotspot_help_action.xml b/briar-android/src/main/res/menu/hotspot_help_action.xml
new file mode 100644
index 000000000..a50486f7e
--- /dev/null
+++ b/briar-android/src/main/res/menu/hotspot_help_action.xml
@@ -0,0 +1,11 @@
+
+
\ No newline at end of file
diff --git a/briar-android/src/main/res/values/color.xml b/briar-android/src/main/res/values/color.xml
index 445b204c0..f0b66de15 100644
--- a/briar-android/src/main/res/values/color.xml
+++ b/briar-android/src/main/res/values/color.xml
@@ -7,6 +7,7 @@
#1b69b6#418cd8
+ #fed69f#fc9403#db3b21
diff --git a/briar-android/src/main/res/values/strings.xml b/briar-android/src/main/res/values/strings.xml
index ab121d58c..b79878b65 100644
--- a/briar-android/src/main/res/values/strings.xml
+++ b/briar-android/src/main/res/values/strings.xml
@@ -160,6 +160,7 @@
SorryUnavailable on your systemStatus:
+ ErrorNo contacts to show
@@ -614,7 +615,8 @@
Learn moreMake future messages in this conversation automatically disappear after 7\u00A0days.
-
+
+ ActionsSend feedback
@@ -689,6 +691,67 @@
Briar can connect to your contacts via the Internet, Wi-Fi or Bluetooth.\n\nAll Internet connections go through the Tor network for privacy.\n\nIf a contact can be reached by multiple methods, Briar uses them in parallel.
+
+ Share Briar offline
+ Share this app with someone nearby without internet connection
+ by using your phone\'s Wi-Fi.
+ \n\nYour phone will open a local hotspot and provide a small website with a download of this app.
+ Start sharing
+ Stop sharing
+ Setting up hotspot…
+ Wi-Fi hotspot
+ Sharing Briar offline
+ Start app sharing
+
+ 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.
+
+ Something went wrong while trying to share the app via Wi-Fi:
+ 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!
+ Error presenting website.\n\nPlease send feedback (with anonymous data) via the Briar app if the issue persists.
+ Warning: This app was installed with Android Studio and 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)
+ Instead of adding the network manually, you can also scan a QR code.
+ Successfully connected
+ Show download info
+ After you are connected to the Wi-Fi, carefully enter this address in your browser.
+ Address (URL)
+ 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. We recommend to undo that after successful installation.
+ To install the downloaded app, you might need to allow your browser to install unknown apps. We recommend to undo that after successful installation.
+
+ 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.
+ Problems visiting the local website:
+ Double check that you entered the address exactly as shown. A small error can make it fail.
+ Ensure that your phone is still connected to the correct Wi-Fi (see above) when you try to access the site.
+ Check that you don\'t have any active firewall apps that may block the access.
+ If you can visit the site, but not download the Briar app, try it with a different web browser app.
+ Nothing works?
+ You can try to save the app as an .apk file to share in some other way. Once on the other device, it can be used to install Briar.
+ \n\nTip: For sharing via Bluetooth, you might need to rename the file to end with .zip first.
+ Save app install file
+
diff --git a/briar-android/src/main/res/xml/settings.xml b/briar-android/src/main/res/xml/settings.xml
index b3cbe0e53..3bd775506 100644
--- a/briar-android/src/main/res/xml/settings.xml
+++ b/briar-android/src/main/res/xml/settings.xml
@@ -24,10 +24,24 @@
app:fragment="org.briarproject.briar.android.settings.NotificationsFragment"
app:icon="@drawable/ic_notifications" />
-
+
+
+
+
+
+