Merge branch '1081-share-app-via-wifi-hotspot' into 'master'

Share app via Wi-Fi hotspot

Closes #1081

See merge request briar/briar!1515
This commit is contained in:
akwizgran
2021-08-04 12:09:08 +00:00
64 changed files with 3304 additions and 48 deletions

View File

@@ -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);

View File

@@ -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);
}
}

View File

@@ -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;
}
};
}
}

View File

@@ -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

View File

@@ -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) {

View File

@@ -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;

View File

@@ -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);
}

View File

@@ -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<Boolean> permissionUpdateCallback;
protected FragmentActivity ctx;
WifiManager wifiManager;
AbstractConditionManager(Consumer<Boolean> 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();
}
}

View File

@@ -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;
}
}
}

View File

@@ -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.
* <p>
* 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<Intent> wifiRequest;
ConditionManager(ActivityResultCaller arc,
Consumer<Boolean> 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));
}
}

View File

@@ -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.
* <p>
* 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<String> locationRequest;
private final ActivityResultLauncher<Intent> wifiRequest;
ConditionManager29(ActivityResultCaller arc,
Consumer<Boolean> 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));
}
}

View File

@@ -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<String> 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<ResolveInfo> 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));
}
}

View File

@@ -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();
}
}
}

View File

@@ -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));
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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();
}
}
}

View File

@@ -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.
* <p>
* 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.
* <p>
* 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);
}
}

View File

@@ -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);
}

View File

@@ -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;
}
}
}

View File

@@ -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<HotspotState> state =
new MutableLiveData<>();
private final MutableLiveEvent<Boolean> peerConnected =
new MutableLiveEvent<>();
private final MutableLiveEvent<Uri> 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<HotspotState> getState() {
return state;
}
LiveEvent<Boolean> getPeerConnectedEvent() {
return peerConnected;
}
LiveEvent<Uri> getSavedApkToUri() {
return savedApkToUri;
}
}

View File

@@ -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<HotspotStarted> 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());
}
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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<NetworkInterface> getNetworkInterfaces() {
try {
Enumeration<NetworkInterface> ifaces =
NetworkInterface.getNetworkInterfaces();
return ifaces == null ? emptyList() : list(ifaces);
} catch (SocketException e) {
logException(LOG, WARNING, e);
return emptyList();
}
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}

View File

@@ -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);
});

View File

@@ -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));

View File

@@ -64,6 +64,8 @@ class ReportViewModel extends AndroidViewModel {
private final MutableLiveEvent<Integer> 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<ReportData> getReportData() {
return reportData;

View File

@@ -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

View File

@@ -262,4 +262,8 @@ class SettingsViewModel extends DbViewModel implements EventListener {
return screenLockTimeout;
}
boolean shouldEnableShareAppViaOfflineHotspot() {
return featureFlags.shouldEnableShareAppViaOfflineHotspot();
}
}

View File

@@ -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

View File

@@ -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);
}
}

View File

@@ -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();
}