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 0a3aefde4..ba06f2a47 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
@@ -14,4 +14,6 @@ public interface FeatureFlags {
boolean shouldEnableConnectViaBluetooth();
boolean shouldEnableTransferData();
+
+ boolean shouldEnableShareAppViaOfflineHotspot();
}
diff --git a/bramble-core/src/test/java/org/briarproject/bramble/test/TestFeatureFlagModule.java b/bramble-core/src/test/java/org/briarproject/bramble/test/TestFeatureFlagModule.java
index 59ec15e23..10e0fc656 100644
--- a/bramble-core/src/test/java/org/briarproject/bramble/test/TestFeatureFlagModule.java
+++ b/bramble-core/src/test/java/org/briarproject/bramble/test/TestFeatureFlagModule.java
@@ -34,6 +34,11 @@ public class TestFeatureFlagModule {
public boolean shouldEnableTransferData() {
return true;
}
+
+ @Override
+ public boolean shouldEnableShareAppViaOfflineHotspot() {
+ return true;
+ }
};
}
}
diff --git a/briar-android/build.gradle b/briar-android/build.gradle
index 16b32a9de..9c15ccb17 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 e325e8aca..738ddbdf0 100644
--- a/briar-android/src/main/AndroidManifest.xml
+++ b/briar-android/src/main/AndroidManifest.xml
@@ -18,6 +18,7 @@
+
@@ -456,6 +457,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..7d41e7122
--- /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.
+
+
To install the downloaded app,
+ you might need to allow your browser to install unknown apps.
+ We recommend to undo that after successful installation.
+
+
+
+
+
+
diff --git a/briar-android/src/main/java/org/briarproject/briar/android/AndroidComponent.java b/briar-android/src/main/java/org/briarproject/briar/android/AndroidComponent.java
index 28f32379c..d2ecf5196 100644
--- a/briar-android/src/main/java/org/briarproject/briar/android/AndroidComponent.java
+++ b/briar-android/src/main/java/org/briarproject/briar/android/AndroidComponent.java
@@ -36,6 +36,11 @@ import org.briarproject.briar.BriarCoreModule;
import org.briarproject.briar.android.attachment.AttachmentModule;
import org.briarproject.briar.android.attachment.media.MediaModule;
import org.briarproject.briar.android.conversation.glide.BriarModelLoader;
+import org.briarproject.briar.android.hotspot.AbstractTabsFragment;
+import org.briarproject.briar.android.hotspot.FallbackFragment;
+import org.briarproject.briar.android.hotspot.HotspotIntroFragment;
+import org.briarproject.briar.android.hotspot.ManualHotspotFragment;
+import org.briarproject.briar.android.hotspot.QrHotspotFragment;
import org.briarproject.briar.android.logging.CachingLogHandler;
import org.briarproject.briar.android.login.SignInReminderReceiver;
import org.briarproject.briar.android.removabledrive.ChooserFragment;
@@ -216,6 +221,16 @@ public interface AndroidComponent
void inject(NotificationsFragment notificationsFragment);
+ void inject(HotspotIntroFragment hotspotIntroFragment);
+
+ void inject(AbstractTabsFragment abstractTabsFragment);
+
+ void inject(QrHotspotFragment qrHotspotFragment);
+
+ void inject(ManualHotspotFragment manualHotspotFragment);
+
+ void inject(FallbackFragment fallbackFragment);
+
void inject(ChooserFragment chooserFragment);
void inject(SendFragment sendFragment);
diff --git a/briar-android/src/main/java/org/briarproject/briar/android/AndroidNotificationManagerImpl.java b/briar-android/src/main/java/org/briarproject/briar/android/AndroidNotificationManagerImpl.java
index f7a175805..05135062e 100644
--- a/briar-android/src/main/java/org/briarproject/briar/android/AndroidNotificationManagerImpl.java
+++ b/briar-android/src/main/java/org/briarproject/briar/android/AndroidNotificationManagerImpl.java
@@ -30,6 +30,7 @@ import org.briarproject.bramble.util.StringUtils;
import org.briarproject.briar.R;
import org.briarproject.briar.android.conversation.ConversationActivity;
import org.briarproject.briar.android.forum.ForumActivity;
+import org.briarproject.briar.android.hotspot.HotspotActivity;
import org.briarproject.briar.android.login.SignInReminderReceiver;
import org.briarproject.briar.android.navdrawer.NavDrawerActivity;
import org.briarproject.briar.android.privategroup.conversation.GroupActivity;
@@ -63,9 +64,11 @@ import static android.app.Notification.DEFAULT_SOUND;
import static android.app.Notification.DEFAULT_VIBRATE;
import static android.app.NotificationManager.IMPORTANCE_DEFAULT;
import static android.app.NotificationManager.IMPORTANCE_LOW;
+import static android.app.PendingIntent.getActivity;
import static android.content.Context.NOTIFICATION_SERVICE;
import static android.content.Intent.FLAG_ACTIVITY_CLEAR_TOP;
import static android.content.Intent.FLAG_ACTIVITY_NEW_TASK;
+import static android.content.Intent.FLAG_ACTIVITY_SINGLE_TOP;
import static android.os.Build.VERSION.SDK_INT;
import static androidx.core.app.NotificationCompat.CATEGORY_MESSAGE;
import static androidx.core.app.NotificationCompat.CATEGORY_SERVICE;
@@ -274,7 +277,7 @@ class AndroidNotificationManagerImpl implements AndroidNotificationManager,
b.setWhen(0); // Don't show the time
b.setOngoing(true);
Intent i = new Intent(appContext, SplashScreenActivity.class);
- b.setContentIntent(PendingIntent.getActivity(appContext, 0, i, 0));
+ b.setContentIntent(getActivity(appContext, 0, i, 0));
if (SDK_INT >= 21) {
b.setCategory(CATEGORY_SERVICE);
b.setVisibility(VISIBILITY_SECRET);
@@ -619,13 +622,11 @@ class AndroidNotificationManagerImpl implements AndroidNotificationManager,
public void showSignInNotification() {
if (blockSignInReminder) return;
if (SDK_INT >= 26) {
- NotificationChannel channel =
- new NotificationChannel(REMINDER_CHANNEL_ID, appContext
- .getString(
- R.string.reminder_notification_channel_title),
- IMPORTANCE_LOW);
- channel.setLockscreenVisibility(
- NotificationCompat.VISIBILITY_SECRET);
+ NotificationChannel channel = new NotificationChannel(
+ REMINDER_CHANNEL_ID, appContext
+ .getString(R.string.reminder_notification_channel_title),
+ IMPORTANCE_LOW);
+ channel.setLockscreenVisibility(VISIBILITY_SECRET);
notificationManager.createNotificationChannel(channel);
}
@@ -652,7 +653,7 @@ class AndroidNotificationManagerImpl implements AndroidNotificationManager,
Intent i = new Intent(appContext, SplashScreenActivity.class);
i.setFlags(FLAG_ACTIVITY_NEW_TASK | FLAG_ACTIVITY_CLEAR_TOP);
- b.setContentIntent(PendingIntent.getActivity(appContext, 0, i, 0));
+ b.setContentIntent(getActivity(appContext, 0, i, 0));
notificationManager.notify(REMINDER_NOTIFICATION_ID, b.build());
}
@@ -720,4 +721,40 @@ class AndroidNotificationManagerImpl implements AndroidNotificationManager,
public void unblockAllBlogPostNotifications() {
androidExecutor.runOnUiThread((Runnable) () -> blockBlogs = false);
}
+
+ @Override
+ public void showHotspotNotification() {
+ if (SDK_INT >= 26) {
+ String channelTitle = appContext
+ .getString(R.string.hotspot_notification_channel_title);
+ NotificationChannel channel = new NotificationChannel(
+ HOTSPOT_CHANNEL_ID, channelTitle, IMPORTANCE_LOW);
+ channel.setLockscreenVisibility(VISIBILITY_SECRET);
+ notificationManager.createNotificationChannel(channel);
+ }
+ BriarNotificationBuilder b =
+ new BriarNotificationBuilder(appContext, HOTSPOT_CHANNEL_ID);
+ b.setSmallIcon(R.drawable.notification_hotspot);
+ b.setColorRes(R.color.briar_brand_green);
+ b.setContentTitle(
+ appContext.getText(R.string.hotspot_notification_title));
+ b.setNotificationCategory(CATEGORY_SERVICE);
+ b.setOngoing(true);
+ b.setShowWhen(true);
+
+ String actionTitle =
+ appContext.getString(R.string.hotspot_button_stop_sharing);
+ Intent i = new Intent(appContext, HotspotActivity.class);
+ i.addFlags(FLAG_ACTIVITY_SINGLE_TOP);
+ i.setAction(ACTION_STOP_HOTSPOT);
+ PendingIntent actionIntent = getActivity(appContext, 0, i, 0);
+ int icon = SDK_INT >= 21 ? R.drawable.ic_portable_wifi_off : 0;
+ b.addAction(icon, actionTitle, actionIntent);
+ notificationManager.notify(HOTSPOT_NOTIFICATION_ID, b.build());
+ }
+
+ @Override
+ public void clearHotspotNotification() {
+ notificationManager.cancel(HOTSPOT_NOTIFICATION_ID);
+ }
}
diff --git a/briar-android/src/main/java/org/briarproject/briar/android/AppModule.java b/briar-android/src/main/java/org/briarproject/briar/android/AppModule.java
index cc3ca07f7..78b27dbce 100644
--- a/briar-android/src/main/java/org/briarproject/briar/android/AppModule.java
+++ b/briar-android/src/main/java/org/briarproject/briar/android/AppModule.java
@@ -36,6 +36,7 @@ import org.briarproject.briar.android.blog.BlogModule;
import org.briarproject.briar.android.contact.ContactListModule;
import org.briarproject.briar.android.contact.add.nearby.AddNearbyContactModule;
import org.briarproject.briar.android.forum.ForumModule;
+import org.briarproject.briar.android.hotspot.HotspotModule;
import org.briarproject.briar.android.introduction.IntroductionModule;
import org.briarproject.briar.android.logging.LoggingModule;
import org.briarproject.briar.android.login.LoginModule;
@@ -94,6 +95,7 @@ import static org.briarproject.briar.android.TestingConstants.IS_DEBUG_BUILD;
GroupListModule.class,
GroupConversationModule.class,
SharingModule.class,
+ HotspotModule.class,
TransferDataModule.class,
})
public class AppModule {
@@ -318,6 +320,11 @@ public class AppModule {
public boolean shouldEnableTransferData() {
return IS_DEBUG_BUILD;
}
+
+ @Override
+ public boolean shouldEnableShareAppViaOfflineHotspot() {
+ return IS_DEBUG_BUILD;
+ }
};
}
}
diff --git a/briar-android/src/main/java/org/briarproject/briar/android/activity/ActivityComponent.java b/briar-android/src/main/java/org/briarproject/briar/android/activity/ActivityComponent.java
index 14f9e0753..683b484e8 100644
--- a/briar-android/src/main/java/org/briarproject/briar/android/activity/ActivityComponent.java
+++ b/briar-android/src/main/java/org/briarproject/briar/android/activity/ActivityComponent.java
@@ -38,6 +38,7 @@ import org.briarproject.briar.android.forum.CreateForumActivity;
import org.briarproject.briar.android.forum.ForumActivity;
import org.briarproject.briar.android.forum.ForumListFragment;
import org.briarproject.briar.android.fragment.ScreenFilterDialogFragment;
+import org.briarproject.briar.android.hotspot.HotspotActivity;
import org.briarproject.briar.android.introduction.ContactChooserFragment;
import org.briarproject.briar.android.introduction.IntroductionActivity;
import org.briarproject.briar.android.introduction.IntroductionMessageFragment;
@@ -177,6 +178,8 @@ public interface ActivityComponent {
void inject(CrashReportActivity crashReportActivity);
+ void inject(HotspotActivity hotspotActivity);
+
void inject(RemovableDriveActivity activity);
// Fragments
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/fragment/ErrorFragment.java b/briar-android/src/main/java/org/briarproject/briar/android/fragment/ErrorFragment.java
index 3b8807f0d..c6e1d85f8 100644
--- a/briar-android/src/main/java/org/briarproject/briar/android/fragment/ErrorFragment.java
+++ b/briar-android/src/main/java/org/briarproject/briar/android/fragment/ErrorFragment.java
@@ -17,7 +17,7 @@ import androidx.annotation.Nullable;
@ParametersNotNullByDefault
public class ErrorFragment extends BaseFragment {
- private static final String TAG = ErrorFragment.class.getName();
+ public static final String TAG = ErrorFragment.class.getName();
private static final String ERROR_MSG = "errorMessage";
@@ -40,8 +40,7 @@ public class ErrorFragment extends BaseFragment {
public void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
- Bundle args = getArguments();
- if (args == null) throw new AssertionError();
+ Bundle args = requireArguments();
errorMessage = args.getString(ERROR_MSG);
}
diff --git a/briar-android/src/main/java/org/briarproject/briar/android/hotspot/AbstractConditionManager.java b/briar-android/src/main/java/org/briarproject/briar/android/hotspot/AbstractConditionManager.java
new file mode 100644
index 000000000..855fd845d
--- /dev/null
+++ b/briar-android/src/main/java/org/briarproject/briar/android/hotspot/AbstractConditionManager.java
@@ -0,0 +1,84 @@
+package org.briarproject.briar.android.hotspot;
+
+import android.content.Context;
+import android.content.DialogInterface;
+import android.net.wifi.WifiManager;
+
+import org.briarproject.briar.R;
+
+import androidx.annotation.StringRes;
+import androidx.appcompat.app.AlertDialog;
+import androidx.core.util.Consumer;
+import androidx.fragment.app.FragmentActivity;
+
+import static android.content.Context.WIFI_SERVICE;
+
+/**
+ * Abstract base class for the ConditionManagers that ensure that the conditions
+ * to open a hotspot are fulfilled. There are different extensions of this for
+ * API levels lower than 29 and 29+.
+ */
+abstract class AbstractConditionManager {
+
+ enum Permission {
+ UNKNOWN, GRANTED, SHOW_RATIONALE, PERMANENTLY_DENIED
+ }
+
+ protected final Consumer permissionUpdateCallback;
+ protected FragmentActivity ctx;
+ WifiManager wifiManager;
+
+ AbstractConditionManager(Consumer permissionUpdateCallback) {
+ this.permissionUpdateCallback = permissionUpdateCallback;
+ }
+
+ /**
+ * Pass a FragmentActivity context here during `onCreateView()`.
+ */
+ void init(FragmentActivity ctx) {
+ this.ctx = ctx;
+ this.wifiManager = (WifiManager) ctx.getApplicationContext()
+ .getSystemService(WIFI_SERVICE);
+ }
+
+ /**
+ * Call this during onStart() in the fragment where the ConditionManager
+ * is used.
+ */
+ abstract void onStart();
+
+ /**
+ * Check if all required conditions are met such that the hotspot can be
+ * started. If any precondition is not met yet, bring up relevant dialogs
+ * asking the user to grant relevant permissions or take relevant actions.
+ *
+ * @return true if conditions are fulfilled and flow can continue.
+ */
+ abstract boolean checkAndRequestConditions();
+
+ void showDenialDialog(FragmentActivity ctx,
+ @StringRes int title, @StringRes int body,
+ DialogInterface.OnClickListener onOkClicked, Runnable onDismiss) {
+ 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.setOnDismissListener(dialog -> onDismiss.run());
+ builder.show();
+ }
+
+ void showRationale(Context ctx, @StringRes int title,
+ @StringRes int body, Runnable onContinueClicked,
+ Runnable onDismiss) {
+ AlertDialog.Builder builder = new AlertDialog.Builder(ctx);
+ builder.setTitle(title);
+ builder.setMessage(body);
+ builder.setNeutralButton(R.string.continue_button,
+ (dialog, which) -> onContinueClicked.run());
+ builder.setOnDismissListener(dialog -> onDismiss.run());
+ builder.show();
+ }
+
+}
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
new file mode 100644
index 000000000..a3b940734
--- /dev/null
+++ b/briar-android/src/main/java/org/briarproject/briar/android/hotspot/AbstractTabsFragment.java
@@ -0,0 +1,129 @@
+package org.briarproject.briar.android.hotspot;
+
+import android.content.Context;
+import android.os.Bundle;
+import android.view.LayoutInflater;
+import android.view.Menu;
+import android.view.MenuInflater;
+import android.view.MenuItem;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.Button;
+
+import com.google.android.material.tabs.TabLayout;
+import com.google.android.material.tabs.TabLayoutMediator;
+
+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.CallSuper;
+import androidx.annotation.Nullable;
+import androidx.fragment.app.Fragment;
+import androidx.lifecycle.ViewModelProvider;
+import androidx.viewpager2.adapter.FragmentStateAdapter;
+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
+public abstract class AbstractTabsFragment extends Fragment {
+
+ static String ARG_FOR_WIFI_CONNECT = "forWifiConnect";
+
+ @Inject
+ ViewModelProvider.Factory viewModelFactory;
+
+ protected HotspotViewModel viewModel;
+
+ protected Button stopButton;
+ protected Button connectedButton;
+
+ @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) {
+ setHasOptionsMenu(true);
+ return inflater
+ .inflate(R.layout.fragment_hotspot_tabs, container, false);
+ }
+
+ @Override
+ @CallSuper
+ public void onViewCreated(View view, @Nullable Bundle savedInstanceState) {
+ TabAdapter tabAdapter = new TabAdapter(this);
+ ViewPager2 viewPager = view.findViewById(R.id.pager);
+ viewPager.setAdapter(tabAdapter);
+ TabLayout tabLayout = view.findViewById(R.id.tabLayout);
+ new TabLayoutMediator(tabLayout, viewPager, (tab, position) -> {
+ // tabs are set in XML, but are just dummies that don't get added
+ if (position == 0) {
+ tab.setText(R.string.hotspot_tab_manual);
+ tab.setIcon(R.drawable.forum_item_create_white);
+ } else if (position == 1) {
+ tab.setText(R.string.qr_code);
+ tab.setIcon(R.drawable.ic_qr_code);
+ } else throw new AssertionError();
+ }).attach();
+
+ stopButton = view.findViewById(R.id.stopButton);
+ stopButton.setOnClickListener(v -> {
+ // also clears hotspot
+ finishAfterTransition(requireActivity());
+ });
+ connectedButton = view.findViewById(R.id.connectedButton);
+ }
+
+ @Override
+ public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) {
+ inflater.inflate(R.menu.hotspot_help_action, menu);
+ super.onCreateOptionsMenu(menu, inflater);
+ }
+
+ @Override
+ public boolean onOptionsItemSelected(MenuItem item) {
+ if (item.getItemId() == R.id.action_help) {
+ Fragment f = new HotspotHelpFragment();
+ String tag = HotspotHelpFragment.TAG;
+ showFragment(getParentFragmentManager(), f, tag);
+ return true;
+ }
+ return super.onOptionsItemSelected(item);
+ }
+
+ protected abstract Fragment getFirstFragment();
+
+ protected abstract Fragment getSecondFragment();
+
+ private class TabAdapter extends FragmentStateAdapter {
+ private TabAdapter(Fragment fragment) {
+ super(fragment);
+ }
+
+ @Override
+ public Fragment createFragment(int position) {
+ if (position == 0) return getFirstFragment();
+ if (position == 1) return getSecondFragment();
+ throw new AssertionError();
+ }
+
+ @Override
+ public int getItemCount() {
+ return 2;
+ }
+ }
+
+}
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..95c0d57fe
--- /dev/null
+++ b/briar-android/src/main/java/org/briarproject/briar/android/hotspot/ConditionManager.java
@@ -0,0 +1,83 @@
+package org.briarproject.briar.android.hotspot;
+
+import android.content.Intent;
+import android.provider.Settings;
+
+import org.briarproject.briar.R;
+
+import java.util.logging.Logger;
+
+import androidx.activity.result.ActivityResultCaller;
+import androidx.activity.result.ActivityResultLauncher;
+import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult;
+import androidx.core.util.Consumer;
+
+import static java.util.logging.Level.INFO;
+import static java.util.logging.Logger.getLogger;
+
+/**
+ * This class ensures that the conditions to open a hotspot are fulfilled on
+ * API levels < 29.
+ *
+ * As soon as {@link #checkAndRequestConditions()} returns true,
+ * all conditions are fulfilled.
+ */
+class ConditionManager extends AbstractConditionManager {
+
+ private static final Logger LOG =
+ getLogger(ConditionManager.class.getName());
+
+ private final ActivityResultLauncher wifiRequest;
+
+ ConditionManager(ActivityResultCaller arc,
+ Consumer permissionUpdateCallback) {
+ super(permissionUpdateCallback);
+ wifiRequest = arc.registerForActivityResult(
+ new StartActivityForResult(),
+ result -> permissionUpdateCallback
+ .accept(wifiManager.isWifiEnabled()));
+ }
+
+ @Override
+ void onStart() {
+ // nothing to do here
+ }
+
+ private boolean areEssentialPermissionsGranted() {
+ if (LOG.isLoggable(INFO)) {
+ LOG.info(String.format("areEssentialPermissionsGranted(): " +
+ "wifiManager.isWifiEnabled()? %b",
+ wifiManager.isWifiEnabled()));
+ }
+ return wifiManager.isWifiEnabled();
+ }
+
+ @Override
+ boolean checkAndRequestConditions() {
+ if (areEssentialPermissionsGranted()) return true;
+
+ if (!wifiManager.isWifiEnabled()) {
+ // Try enabling the Wifi and return true if that seems to have been
+ // successful, i.e. "Wifi is either already in the requested state, or
+ // in progress toward the requested state".
+ if (wifiManager.setWifiEnabled(true)) {
+ LOG.info("Enabled wifi");
+ return true;
+ }
+
+ // Wifi is not enabled and we can't seem to enable it, so ask the user
+ // to enable it for us.
+ showRationale(ctx, R.string.wifi_settings_title,
+ R.string.wifi_settings_request_enable_body,
+ this::requestEnableWiFi,
+ () -> permissionUpdateCallback.accept(false));
+ }
+
+ return false;
+ }
+
+ private void requestEnableWiFi() {
+ wifiRequest.launch(new Intent(Settings.ACTION_WIFI_SETTINGS));
+ }
+
+}
diff --git a/briar-android/src/main/java/org/briarproject/briar/android/hotspot/ConditionManager29.java b/briar-android/src/main/java/org/briarproject/briar/android/hotspot/ConditionManager29.java
new file mode 100644
index 000000000..036225899
--- /dev/null
+++ b/briar-android/src/main/java/org/briarproject/briar/android/hotspot/ConditionManager29.java
@@ -0,0 +1,136 @@
+package org.briarproject.briar.android.hotspot;
+
+import android.content.Intent;
+import android.provider.Settings;
+
+import org.briarproject.briar.R;
+
+import java.util.logging.Logger;
+
+import androidx.activity.result.ActivityResultCaller;
+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.annotation.RequiresApi;
+import androidx.core.util.Consumer;
+
+import static android.Manifest.permission.ACCESS_FINE_LOCATION;
+import static androidx.core.app.ActivityCompat.shouldShowRequestPermissionRationale;
+import static java.lang.Boolean.TRUE;
+import static java.util.logging.Level.INFO;
+import static java.util.logging.Logger.getLogger;
+import static org.briarproject.briar.android.util.UiUtils.getGoToSettingsListener;
+
+/**
+ * This class ensures that the conditions to open a hotspot are fulfilled on
+ * API levels >= 29.
+ *
+ * As soon as {@link #checkAndRequestConditions()} returns true,
+ * all conditions are fulfilled.
+ */
+@RequiresApi(29)
+class ConditionManager29 extends AbstractConditionManager {
+
+ private static final Logger LOG =
+ getLogger(ConditionManager29.class.getName());
+
+ private Permission locationPermission = Permission.UNKNOWN;
+
+ private final ActivityResultLauncher locationRequest;
+ private final ActivityResultLauncher wifiRequest;
+
+ ConditionManager29(ActivityResultCaller arc,
+ Consumer permissionUpdateCallback) {
+ super(permissionUpdateCallback);
+ locationRequest = arc.registerForActivityResult(
+ new RequestPermission(), granted -> {
+ onRequestPermissionResult(granted);
+ permissionUpdateCallback.accept(TRUE.equals(granted));
+ });
+ wifiRequest = arc.registerForActivityResult(
+ new StartActivityForResult(),
+ result -> permissionUpdateCallback
+ .accept(wifiManager.isWifiEnabled())
+ );
+ }
+
+ @Override
+ void onStart() {
+ locationPermission = Permission.UNKNOWN;
+ }
+
+ private boolean areEssentialPermissionsGranted() {
+ boolean isWifiEnabled = wifiManager.isWifiEnabled();
+ if (LOG.isLoggable(INFO)) {
+ LOG.info(String.format("areEssentialPermissionsGranted(): " +
+ "locationPermission? %s, " +
+ "wifiManager.isWifiEnabled()? %b",
+ locationPermission, isWifiEnabled));
+ }
+ return locationPermission == Permission.GRANTED && isWifiEnabled;
+ }
+
+ @Override
+ boolean checkAndRequestConditions() {
+ if (areEssentialPermissionsGranted()) return true;
+
+ if (locationPermission == Permission.UNKNOWN) {
+ locationRequest.launch(ACCESS_FINE_LOCATION);
+ return false;
+ }
+
+ // If the location permission has been permanently denied, ask the
+ // user to change the setting
+ if (locationPermission == Permission.PERMANENTLY_DENIED) {
+ showDenialDialog(ctx, R.string.permission_location_title,
+ R.string.permission_hotspot_location_denied_body,
+ getGoToSettingsListener(ctx),
+ () -> permissionUpdateCallback.accept(false));
+ return false;
+ }
+
+ // Should we show the rationale for location permission?
+ if (locationPermission == Permission.SHOW_RATIONALE) {
+ showRationale(ctx, R.string.permission_location_title,
+ R.string.permission_hotspot_location_request_body,
+ this::requestPermissions,
+ () -> permissionUpdateCallback.accept(false));
+ return false;
+ }
+
+ // If Wifi is not enabled, we show the rationale for enabling Wifi?
+ if (!wifiManager.isWifiEnabled()) {
+ showRationale(ctx, R.string.wifi_settings_title,
+ R.string.wifi_settings_request_enable_body,
+ this::requestEnableWiFi,
+ () -> permissionUpdateCallback.accept(false));
+ return false;
+ }
+
+ // we shouldn't usually reach this point, but if we do, return false
+ // anyway to force a recheck. Maybe some condition changed in the
+ // meantime.
+ return false;
+ }
+
+ private 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;
+ }
+ }
+
+ private void requestPermissions() {
+ locationRequest.launch(ACCESS_FINE_LOCATION);
+ }
+
+ private void requestEnableWiFi() {
+ wifiRequest.launch(new Intent(Settings.Panel.ACTION_WIFI));
+ }
+
+}
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..56c88f061
--- /dev/null
+++ b/briar-android/src/main/java/org/briarproject/briar/android/hotspot/FallbackFragment.java
@@ -0,0 +1,127 @@
+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 org.briarproject.briar.android.util.ActivityLaunchers.CreateDocumentAdvanced;
+
+import java.util.List;
+
+import javax.inject.Inject;
+
+import androidx.activity.result.ActivityResultLauncher;
+import androidx.annotation.Nullable;
+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.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 CreateDocumentAdvanced(),
+ 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_fallback, 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, this::shareUri);
+ }
+
+ 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);
+ }
+
+ void shareUri(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 = 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);
+ }
+ }
+ 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..359b86f2d
--- /dev/null
+++ b/briar-android/src/main/java/org/briarproject/briar/android/hotspot/HotspotActivity.java
@@ -0,0 +1,143 @@
+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.wasNotYetConsumed()) {
+ 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..773faf510
--- /dev/null
+++ b/briar-android/src/main/java/org/briarproject/briar/android/hotspot/HotspotFragment.java
@@ -0,0 +1,52 @@
+package org.briarproject.briar.android.hotspot;
+
+import android.os.Bundle;
+import android.view.View;
+import android.widget.Toast;
+
+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 {
+
+ 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;
+ Toast.makeText(requireContext(), R.string.hotspot_peer_connected,
+ Toast.LENGTH_LONG).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..fd6112bd0
--- /dev/null
+++ b/briar-android/src/main/java/org/briarproject/briar/android/hotspot/HotspotIntroFragment.java
@@ -0,0 +1,127 @@
+package org.briarproject.briar.android.hotspot;
+
+import android.content.Context;
+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.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.os.Build.VERSION.SDK_INT;
+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 Button startButton;
+ private ProgressBar progressBar;
+ private TextView progressTextView;
+
+ private final AbstractConditionManager conditionManager = SDK_INT < 29 ?
+ new ConditionManager(this, this::onPermissionUpdate) :
+ new ConditionManager29(this, this::onPermissionUpdate);
+
+ @Override
+ public void onAttach(Context context) {
+ super.onAttach(context);
+ FragmentActivity activity = requireActivity();
+ getAndroidComponent(activity).inject(this);
+ viewModel = new ViewModelProvider(activity, viewModelFactory)
+ .get(HotspotViewModel.class);
+ }
+
+ @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(this::onButtonClick);
+
+ conditionManager.init(requireActivity());
+
+ return v;
+ }
+
+ @Override
+ public void onStart() {
+ super.onStart();
+ conditionManager.onStart();
+ }
+
+ private void onButtonClick(View view) {
+ startButton.setEnabled(false);
+ startHotspotIfConditionsFulfilled();
+ }
+
+ private void startHotspotIfConditionsFulfilled() {
+ if (conditionManager.checkAndRequestConditions()) {
+ showInstallWarningIfNeeded();
+ beginDelayedTransition((ViewGroup) requireView());
+ startButton.setVisibility(INVISIBLE);
+ progressBar.setVisibility(VISIBLE);
+ progressTextView.setVisibility(VISIBLE);
+ viewModel.startHotspot();
+ }
+ }
+
+ private void onPermissionUpdate(boolean recheckPermissions) {
+ startButton.setEnabled(true);
+ if (recheckPermissions) {
+ startHotspotIfConditionsFulfilled();
+ }
+ }
+
+ 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..8d4300c31
--- /dev/null
+++ b/briar-android/src/main/java/org/briarproject/briar/android/hotspot/HotspotManager.java
@@ -0,0 +1,453 @@
+package org.briarproject.briar.android.hotspot;
+
+import android.annotation.SuppressLint;
+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.os.PowerManager;
+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.POWER_SERVICE;
+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 android.os.PowerManager.FULL_WAKE_LOCK;
+import static java.util.Objects.requireNonNull;
+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.briar.android.util.UiUtils.handleException;
+
+@MethodsNotNullByDefault
+@ParametersNotNullByDefault
+class HotspotManager {
+
+ interface HotspotListener {
+ @UiThread
+ void onStartingHotspot();
+
+ @IoExecutor
+ void onHotspotStarted(NetworkConfig networkConfig);
+
+ @UiThread
+ void onDeviceConnected();
+
+ @UiThread
+ void onHotspotError(String error);
+ }
+
+ private static final Logger LOG = getLogger(HotspotManager.class.getName());
+
+ private static final int MAX_FRAMEWORK_ATTEMPTS = 5;
+ 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 PowerManager powerManager;
+ private final Handler handler;
+ private final String lockTag;
+
+ private HotspotListener listener;
+ private WifiManager.WifiLock wifiLock;
+ private PowerManager.WakeLock wakeLock;
+ private WifiP2pManager.Channel channel;
+ @Nullable
+ @RequiresApi(29)
+ private volatile NetworkConfig savedNetworkConfig = null;
+
+ @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);
+ powerManager = (PowerManager) ctx.getSystemService(POWER_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();
+ acquireLocks();
+ startWifiP2pFramework(1);
+ }
+
+ /**
+ * As soon as Wifi is enabled, we try starting the WifiP2p framework.
+ * If Wifi has just been enabled, it is possible that will fail. If that
+ * happens we try again for MAX_FRAMEWORK_ATTEMPTS times after a delay of
+ * RETRY_DELAY_MILLIS after each attempt.
+ *
+ * Rationale: it can take a few milliseconds for WifiP2p to become available
+ * after enabling Wifi. Depending on the API level it is possible to check this
+ * using {@link WifiP2pManager#requestP2pState} or register a BroadcastReceiver
+ * on the WIFI_P2P_STATE_CHANGED_ACTION to get notified when WifiP2p is really
+ * available. Trying to implement a solution that works reliably using these
+ * checks turned out to be a long rabbit-hole with lots of corner cases and
+ * workarounds for specific situations.
+ * Instead we now rely on this trial-and-error approach of just starting
+ * the framework and retrying if it fails.
+ *
+ * We'll realize that the framework is busy when the ActionListener passed
+ * to {@link WifiP2pManager#createGroup} is called with onFailure(BUSY)
+ */
+ @UiThread
+ private void startWifiP2pFramework(int attempt) {
+ if (LOG.isLoggable(INFO)) {
+ LOG.info("startWifiP2pFramework attempt: " + attempt);
+ }
+ /*
+ * It is important that we call WifiP2pManager#initialize again
+ * for every attempt to starting the framework because otherwise,
+ * createGroup() will continue to fail with a BUSY state.
+ */
+ channel = wifiP2pManager.initialize(ctx, ctx.getMainLooper(), null);
+ if (channel == null) {
+ releaseHotspotWithError(
+ ctx.getString(R.string.hotspot_error_no_wifi_direct));
+ return;
+ }
+
+ ActionListener listener = new ActionListener() {
+ @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 (LOG.isLoggable(INFO)) {
+ LOG.info("onFailure: " + reason);
+ }
+ if (reason == BUSY) {
+ // WifiP2p not ready yet or hotspot already running
+ restartWifiP2pFramework(attempt);
+ } 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));
+ }
+ }
+ };
+
+ try {
+ if (SDK_INT >= 29) {
+ Runnable createGroup = () -> {
+ NetworkConfig c = requireNonNull(savedNetworkConfig);
+ WifiP2pConfig config = new WifiP2pConfig.Builder()
+ .setGroupOperatingBand(GROUP_OWNER_BAND_2GHZ)
+ .setNetworkName(c.ssid)
+ .setPassphrase(c.password)
+ .build();
+ wifiP2pManager.createGroup(channel, config, listener);
+ };
+ if (savedNetworkConfig == null) {
+ // load savedNetworkConfig before starting hotspot
+ dbExecutor.execute(() -> {
+ loadSavedNetworkConfig();
+ androidExecutor.runOnUiThread(createGroup);
+ });
+ } else {
+ // savedNetworkConfig was already loaded, create group now
+ createGroup.run();
+ }
+ } else {
+ wifiP2pManager.createGroup(channel, listener);
+ }
+ } catch (SecurityException e) {
+ // this should never happen, because we request permissions before
+ throw new AssertionError(e);
+ }
+ }
+
+ @UiThread
+ private void restartWifiP2pFramework(int attempt) {
+ LOG.info("retrying to start WifiP2p framework");
+ if (attempt < MAX_FRAMEWORK_ATTEMPTS) {
+ if (SDK_INT >= 27 && channel != null) channel.close();
+ channel = null;
+ handler.postDelayed(() -> startWifiP2pFramework(attempt + 1),
+ RETRY_DELAY_MILLIS);
+ } else {
+ releaseHotspotWithError(
+ ctx.getString(R.string.hotspot_error_framework_busy));
+ }
+ }
+
+ @UiThread
+ void stopWifiP2pHotspot() {
+ if (channel == null) return;
+ wifiP2pManager.removeGroup(channel, new ActionListener() {
+ @Override
+ public void onSuccess() {
+ closeChannelAndReleaseLocks();
+ }
+
+ @Override
+ public void onFailure(int reason) {
+ // not propagating back error
+ if (LOG.isLoggable(WARNING)) {
+ LOG.warning("Error removing Wifi P2P group: " + reason);
+ }
+ closeChannelAndReleaseLocks();
+ }
+ });
+ }
+
+ @SuppressLint("WakelockTimeout")
+ private void acquireLocks() {
+ // FLAG_KEEP_SCREEN_ON is not respected on some Huawei devices.
+ wakeLock = powerManager.newWakeLock(FULL_WAKE_LOCK, lockTag);
+ wakeLock.acquire();
+ // 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();
+ }
+
+ @UiThread
+ private void releaseHotspotWithError(String error) {
+ listener.onHotspotError(error);
+ closeChannelAndReleaseLocks();
+ }
+
+ @UiThread
+ private void closeChannelAndReleaseLocks() {
+ if (SDK_INT >= 27 && channel != null) channel.close();
+ channel = null;
+ if (wakeLock.isHeld()) wakeLock.release();
+ if (wifiLock.isHeld()) wifiLock.release();
+ }
+
+ @UiThread
+ 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-")) {
+ LOG.info("received networkName without prefix 'DIRECT-'");
+ return false;
+ } else if (SDK_INT >= 29) {
+ // if we get here, the savedNetworkConfig must have a value
+ String networkName = requireNonNull(savedNetworkConfig).ssid;
+ if (!networkName.equals(group.getNetworkName())) {
+ LOG.info("expected networkName does not match received one");
+ return false;
+ }
+ }
+ return true;
+ }
+
+ @UiThread
+ private void retryRequestingGroupInfo(int attempt) {
+ LOG.info("retrying to request group info");
+ // 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() {
+ LOG.info("requestGroupInfo for connection");
+ GroupInfoListener groupListener = group -> {
+ if (group == null || group.getClientList().isEmpty()) {
+ handler.postDelayed(this::requestGroupInfoForConnection,
+ RETRY_DELAY_MILLIS);
+ } else {
+ 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 + ";;";
+ }
+
+ // exclude chars that are easy to confuse: 0 O, 5 S, 1 l I
+ private static final String chars =
+ "2346789ABCDEFGHJKLMNPQRTUVWXYZabcdefghijkmnopqrstuvwxyz";
+
+ private String getRandomString(int length) {
+ char[] c = new char[length];
+ for (int i = 0; i < length; i++) {
+ c[i] = chars.charAt(random.nextInt(chars.length()));
+ }
+ return new String(c);
+ }
+
+}
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..a152874eb
--- /dev/null
+++ b/briar-android/src/main/java/org/briarproject/briar/android/hotspot/HotspotState.java
@@ -0,0 +1,88 @@
+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;
+ }
+
+ @UiThread
+ boolean wasNotYetConsumed() {
+ return !consumed;
+ }
+
+ /**
+ * 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
+ void consume() {
+ consumed = true;
+ }
+ }
+
+ 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..ee9a984e7
--- /dev/null
+++ b/briar-android/src/main/java/org/briarproject/briar/android/hotspot/HotspotViewModel.java
@@ -0,0 +1,224 @@
+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 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)));
+ stopHotspot();
+ }
+
+ 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..4f2fa72f7
--- /dev/null
+++ b/briar-android/src/main/java/org/briarproject/briar/android/hotspot/ManualHotspotFragment.java
@@ -0,0 +1,119 @@
+package org.briarproject.briar.android.hotspot;
+
+import android.content.Context;
+import android.os.Bundle;
+import android.text.SpannableString;
+import android.text.method.LinkMovementMethod;
+import android.text.style.ClickableSpan;
+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 androidx.viewpager2.widget.ViewPager2;
+
+import static android.text.Spanned.SPAN_EXCLUSIVE_EXCLUSIVE;
+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);
+
+ Consumer consumer;
+ if (requireArguments().getBoolean(ARG_FOR_WIFI_CONNECT)) {
+ linkify(manualIntroView, 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);
+ };
+ } else {
+ linkify(manualIntroView, R.string.hotspot_manual_site);
+ ssidLabelView.setText(R.string.hotspot_manual_site_address);
+ consumer = state -> ssidView.setText(state.getWebsiteConfig().url);
+ 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);
+ }
+ });
+ }
+
+ private void linkify(TextView textView, int resPattern) {
+ String pattern = getString(resPattern);
+ String replacement = getString(R.string.hotspot_scanning_a_qr_code);
+ String text = String.format(pattern, replacement);
+ int start = pattern.indexOf("%s");
+ int end = start + replacement.length();
+ SpannableString spannable = new SpannableString(text);
+ ClickableSpan clickable = new ClickableSpan() {
+
+ @Override
+ public void onClick(View textView) {
+ ViewPager2 pager = requireActivity().findViewById(R.id.pager);
+ pager.setCurrentItem(1);
+ }
+
+ };
+ spannable.setSpan(clickable, start, end, SPAN_EXCLUSIVE_EXCLUSIVE);
+
+ textView.setText(spannable);
+ textView.setMovementMethod(LinkMovementMethod.getInstance());
+ }
+}
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..a613804f5
--- /dev/null
+++ b/briar-android/src/main/java/org/briarproject/briar/android/hotspot/QrHotspotFragment.java
@@ -0,0 +1,86 @@
+package org.briarproject.briar.android.hotspot;
+
+import android.content.Context;
+import android.graphics.Bitmap;
+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 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.hotspot.HotspotState.HotspotStarted;
+
+import javax.inject.Inject;
+
+import androidx.annotation.Nullable;
+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);
+
+ boolean forWifi = requireArguments().getBoolean(ARG_FOR_WIFI_CONNECT);
+
+ qrIntroView.setText(forWifi ? R.string.hotspot_qr_wifi :
+ R.string.hotspot_qr_site);
+
+ viewModel.getState().observe(getViewLifecycleOwner(), state -> {
+ if (state instanceof HotspotStarted) {
+ HotspotStarted s = (HotspotStarted) state;
+ Bitmap qrCode = forWifi ? s.getNetworkConfig().qrCode :
+ s.getWebsiteConfig().qrCode;
+ if (qrCode == null) {
+ Toast.makeText(requireContext(), R.string.error,
+ Toast.LENGTH_SHORT).show();
+ qrCodeView.setImageResource(R.drawable.ic_image_broken);
+ } else {
+ qrCodeView.setImageBitmap(qrCode);
+ }
+ }
+ });
+ 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..c5a720b37
--- /dev/null
+++ b/briar-android/src/main/java/org/briarproject/briar/android/hotspot/WebServerManager.java
@@ -0,0 +1,127 @@
+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 androidx.annotation.UiThread;
+
+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 volatile WebServerListener listener;
+
+ @Inject
+ WebServerManager(Application ctx) {
+ webServer = new WebServer(ctx);
+ dm = ctx.getResources().getDisplayMetrics();
+ }
+
+ @UiThread
+ 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 0f99caf3c..870285954 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
@@ -38,7 +38,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 df8543c1a..5f4fd2f16 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
@@ -36,6 +36,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;
@@ -85,6 +86,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 e62e75397..ae1e43131 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
@@ -61,6 +61,7 @@ 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;
@@ -111,6 +112,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
@@ -137,13 +139,18 @@ public class UiUtils {
public static void showFragment(FragmentManager fm, Fragment f,
@Nullable String tag) {
- fm.beginTransaction()
+ 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)
- .addToBackStack(tag)
- .commit();
+ .replace(R.id.fragmentContainer, f, tag);
+ if (addToBackStack) ta.addToBackStack(tag);
+ ta.commit();
}
public static String getContactDisplayName(Author author,
@@ -410,17 +417,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);
}
@@ -546,4 +561,5 @@ public class UiUtils {
activity.getWindow().setSoftInputMode(SOFT_INPUT_ADJUST_RESIZE |
SOFT_INPUT_STATE_HIDDEN);
}
+
}
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 765425720..908b27322 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";
@@ -42,12 +43,15 @@ public interface AndroidNotificationManager {
String ONGOING_CHANNEL_OLD_ID = "zForegroundService";
String ONGOING_CHANNEL_ID = "zForegroundService2";
String REMINDER_CHANNEL_ID = "zSignInReminder";
+ String HOTSPOT_CHANNEL_ID = "zHotspot";
+
// This channel is no longer used - keep the ID so we can remove the
// channel from existing installations
String FAILURE_CHANNEL_ID = "zStartupFailure";
// Actions for pending intents
String ACTION_DISMISS_REMINDER = "dismissReminder";
+ String ACTION_STOP_HOTSPOT = "stopHotspot";
Notification getForegroundNotification();
@@ -96,4 +100,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..2bf918c5b
--- /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_fallback.xml b/briar-android/src/main/res/layout/fragment_hotspot_fallback.xml
new file mode 100644
index 000000000..1fbb39157
--- /dev/null
+++ b/briar-android/src/main/res/layout/fragment_hotspot_fallback.xml
@@ -0,0 +1,53 @@
+
+
+
+
+
+
+
+
+
+
+
+
\ 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..cbc0b148e
--- /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..6e12c4c7c
--- /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..5c6dbf048
--- /dev/null
+++ b/briar-android/src/main/res/layout/fragment_hotspot_manual.xml
@@ -0,0 +1,85 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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_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 20b8cfdec..23ad65582 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
@@ -615,7 +616,8 @@
Learn moreMake future messages in this conversation automatically disappear after 7\u00A0days.
-
+
+ ActionsSend feedback
@@ -690,6 +692,73 @@
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 start a Wi-Fi hotspot. People nearby can connect to the hotspot and download the Briar app from your phone.
+ Start hotspot
+ Stop hotspot
+ Setting up hotspot…
+ Wi-Fi hotspot
+ Sharing Briar offline
+ Next
+
+ 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 permission to enable Wi-Fi, but Briar needs to use Wi-Fi.\n\nPlease consider enabling it.
+
+ Manual
+
+ scanning a QR code
+
+
+
+ Your phone is providing a Wi-Fi hotspot. People who want to download Briar can connect to the hotspot by entering the details below or %s. When they have connected to the hotspot, press \'Next\'.
+ Network name (SSID)
+ Your phone is providing a Wi-Fi hotspot. People who want to download Briar can connect to the hotspot by scanning this QR code. When they have connected to the hotspot, press \'Next\'.
+ Successfully connected
+
+
+
+ Your phone is providing a Wi-Fi hotspot. People who are connected to the hotspot can download Briar by typing the following link in a web browser or %s.
+ Address (URL)
+ Your phone is providing a Wi-Fi hotspot. People who are connected to the hotspot can download Briar by scanning this QR code.
+
+
+ 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 disabling the \"Unknown sources\" setting after installing the app.
+ To install the downloaded app, you might need to allow your browser to install unknown apps. After installing the app, we recommend removing the browser\'s permission 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.
+ 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.
+ If you have a firewall app, check that it isn\'t blocking 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 the file has been transferred to 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
+
+
+ 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.
+ Unable to start the hotspot.\n\nIf you have another hotspot running or are sharing your Internet connection via Wi-Fi, try stopping that and try again afterwards.
+
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" />
-
+
+
+
+
+
+