Port code from Offline hotspot test app

This commit is contained in:
Torsten Grote
2021-05-17 11:26:10 -03:00
parent 16b79e0482
commit 28d87dd153
21 changed files with 1143 additions and 80 deletions

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

@@ -28,6 +28,7 @@ import androidx.viewpager2.widget.ViewPager2;
import static androidx.core.app.ActivityCompat.finishAfterTransition;
import static org.briarproject.briar.android.AppModule.getAndroidComponent;
import static org.briarproject.briar.android.util.UiUtils.showFragment;
@MethodsNotNullByDefault
@ParametersNotNullByDefault
@@ -95,15 +96,9 @@ public abstract class AbstractTabsFragment extends Fragment {
@Override
public boolean onOptionsItemSelected(MenuItem item) {
if (item.getItemId() == R.id.action_help) {
getParentFragmentManager().beginTransaction()
.setCustomAnimations(R.anim.step_next_in,
R.anim.step_previous_out,
R.anim.step_previous_in,
R.anim.step_next_out)
.replace(R.id.fragmentContainer, new HotspotHelpFragment(),
HotspotHelpFragment.TAG)
.addToBackStack(HotspotHelpFragment.TAG)
.commit();
Fragment f = new HotspotHelpFragment();
String tag = HotspotHelpFragment.TAG;
showFragment(getParentFragmentManager(), f, tag);
return true;
}
return super.onOptionsItemSelected(item);

View File

@@ -0,0 +1,168 @@
package org.briarproject.briar.android.hotspot;
import android.content.DialogInterface.OnClickListener;
import android.content.Intent;
import android.net.wifi.WifiManager;
import android.provider.Settings;
import org.briarproject.briar.R;
import androidx.activity.result.ActivityResult;
import androidx.activity.result.ActivityResultLauncher;
import androidx.annotation.StringRes;
import androidx.appcompat.app.AlertDialog;
import androidx.fragment.app.FragmentActivity;
import static android.Manifest.permission.ACCESS_FINE_LOCATION;
import static android.content.Context.WIFI_SERVICE;
import static android.os.Build.VERSION.SDK_INT;
import static androidx.core.app.ActivityCompat.shouldShowRequestPermissionRationale;
import static org.briarproject.briar.android.util.UiUtils.getGoToSettingsListener;
/**
* This class ensures that the conditions to open a hotspot are fulfilled.
* <p>
* Be sure to call {@link #onRequestPermissionResult(Boolean)} and
* {@link #onRequestWifiEnabledResult()} when you get the
* {@link ActivityResult}.
* <p>
* As soon as {@link #checkAndRequestConditions()} returns true,
* all conditions are fulfilled.
*/
class ConditionManager {
private enum Permission {
UNKNOWN, GRANTED, SHOW_RATIONALE, PERMANENTLY_DENIED
}
private Permission locationPermission = Permission.UNKNOWN;
private Permission wifiSetting = Permission.SHOW_RATIONALE;
private final FragmentActivity ctx;
private final WifiManager wifiManager;
private final ActivityResultLauncher<String> locationRequest;
private final ActivityResultLauncher<Intent> wifiRequest;
ConditionManager(FragmentActivity ctx,
ActivityResultLauncher<String> locationRequest,
ActivityResultLauncher<Intent> wifiRequest) {
this.ctx = ctx;
this.wifiManager = (WifiManager) ctx.getApplicationContext()
.getSystemService(WIFI_SERVICE);
this.locationRequest = locationRequest;
this.wifiRequest = wifiRequest;
}
/**
* Call this to reset state when UI starts,
* because state might have changed.
*/
void resetPermissions() {
locationPermission = Permission.UNKNOWN;
wifiSetting = Permission.SHOW_RATIONALE;
}
/**
* This makes a request for location permission.
* If {@link #checkAndRequestConditions()} returns true, you can continue.
*/
void startConditionChecks() {
locationRequest.launch(ACCESS_FINE_LOCATION);
}
/**
* @return true if conditions are fulfilled and flow can continue.
*/
boolean checkAndRequestConditions() {
if (areEssentialPermissionsGranted()) return true;
// If an essential permission has been permanently denied, ask the
// user to change the setting
if (locationPermission == Permission.PERMANENTLY_DENIED) {
showDenialDialog(R.string.permission_location_title,
R.string.permission_hotspot_location_denied_body,
getGoToSettingsListener(ctx));
return false;
}
if (wifiSetting == Permission.PERMANENTLY_DENIED) {
showDenialDialog(R.string.wifi_settings_title,
R.string.wifi_settings_request_denied_body,
(d, w) -> requestEnableWiFi());
return false;
}
// Should we show the rationale for location permission or Wi-Fi?
if (locationPermission == Permission.SHOW_RATIONALE) {
showRationale(R.string.permission_location_title,
R.string.permission_hotspot_location_request_body,
this::requestPermissions);
} else if (wifiSetting == Permission.SHOW_RATIONALE) {
showRationale(R.string.wifi_settings_title,
R.string.wifi_settings_request_enable_body,
this::requestEnableWiFi);
}
return false;
}
void onRequestPermissionResult(Boolean granted) {
if (granted != null && granted) {
locationPermission = Permission.GRANTED;
} else if (shouldShowRequestPermissionRationale(ctx,
ACCESS_FINE_LOCATION)) {
locationPermission = Permission.SHOW_RATIONALE;
} else {
locationPermission = Permission.PERMANENTLY_DENIED;
}
}
void onRequestWifiEnabledResult() {
wifiSetting = wifiManager.isWifiEnabled() ? Permission.GRANTED :
Permission.PERMANENTLY_DENIED;
}
private boolean areEssentialPermissionsGranted() {
if (SDK_INT < 29) {
if (!wifiManager.isWifiEnabled()) {
//noinspection deprecation
return wifiManager.setWifiEnabled(true);
}
return true;
} else {
return locationPermission == Permission.GRANTED
&& wifiManager.isWifiEnabled();
}
}
private void showDenialDialog(@StringRes int title, @StringRes int body,
OnClickListener onOkClicked) {
AlertDialog.Builder builder = new AlertDialog.Builder(ctx);
builder.setTitle(title);
builder.setMessage(body);
builder.setPositiveButton(R.string.ok, onOkClicked);
builder.setNegativeButton(R.string.cancel,
(dialog, which) -> ctx.supportFinishAfterTransition());
builder.show();
}
private void showRationale(@StringRes int title, @StringRes int body,
Runnable onContinueClicked) {
AlertDialog.Builder builder = new AlertDialog.Builder(ctx);
builder.setTitle(title);
builder.setMessage(body);
builder.setNeutralButton(R.string.continue_button,
(dialog, which) -> onContinueClicked.run());
builder.show();
}
private void requestPermissions() {
locationRequest.launch(ACCESS_FINE_LOCATION);
}
private void requestEnableWiFi() {
Intent i = SDK_INT < 29 ?
new Intent(Settings.ACTION_WIFI_SETTINGS) :
new Intent(Settings.Panel.ACTION_WIFI);
wifiRequest.launch(i);
}
}

View File

@@ -3,19 +3,25 @@ package org.briarproject.briar.android.hotspot;
import android.content.Intent;
import android.os.Bundle;
import android.view.MenuItem;
import android.widget.Toast;
import org.briarproject.bramble.api.nullsafety.MethodsNotNullByDefault;
import org.briarproject.bramble.api.nullsafety.ParametersNotNullByDefault;
import org.briarproject.briar.R;
import org.briarproject.briar.android.activity.ActivityComponent;
import org.briarproject.briar.android.activity.BriarActivity;
import org.briarproject.briar.android.hotspot.HotspotState.HotspotError;
import org.briarproject.briar.android.hotspot.HotspotState.HotspotStarted;
import javax.annotation.Nullable;
import javax.inject.Inject;
import androidx.appcompat.app.ActionBar;
import androidx.fragment.app.FragmentManager;
import androidx.lifecycle.ViewModelProvider;
import static android.widget.Toast.LENGTH_LONG;
import static org.briarproject.briar.android.util.UiUtils.showFragment;
import static org.briarproject.briar.api.android.AndroidNotificationManager.ACTION_STOP_HOTSPOT;
@MethodsNotNullByDefault
@@ -44,7 +50,21 @@ public class HotspotActivity extends BriarActivity {
ab.setDisplayHomeAsUpEnabled(true);
}
// TODO observe viewmodel state and show error or HotspotFragment
viewModel.getState().observe(this, hotspotState -> {
if (hotspotState instanceof HotspotStarted) {
FragmentManager fm = getSupportFragmentManager();
String tag = HotspotFragment.TAG;
// check if fragment is already added
// to not lose state on configuration changes
if (fm.findFragmentByTag(tag) == null) {
showFragment(fm, new HotspotFragment(), tag);
}
} else if (hotspotState instanceof HotspotError) {
// TODO ErrorFragment
String error = ((HotspotError) hotspotState).getError();
Toast.makeText(this, error, LENGTH_LONG).show();
}
});
if (state == null) {
getSupportFragmentManager().beginTransaction()

View File

@@ -5,11 +5,12 @@ import android.view.View;
import org.briarproject.bramble.api.nullsafety.MethodsNotNullByDefault;
import org.briarproject.bramble.api.nullsafety.ParametersNotNullByDefault;
import org.briarproject.briar.R;
import androidx.annotation.Nullable;
import androidx.fragment.app.Fragment;
import static org.briarproject.briar.android.util.UiUtils.showFragment;
@MethodsNotNullByDefault
@ParametersNotNullByDefault
public class HotspotFragment extends AbstractTabsFragment {
@@ -21,14 +22,9 @@ public class HotspotFragment extends AbstractTabsFragment {
super.onViewCreated(view, savedInstanceState);
// no need to call into the ViewModel here
connectedButton.setOnClickListener(v -> {
getParentFragmentManager().beginTransaction()
.setCustomAnimations(R.anim.step_next_in,
R.anim.step_previous_out, R.anim.step_previous_in,
R.anim.step_next_out)
.replace(R.id.fragmentContainer, new WebsiteFragment(),
WebsiteFragment.TAG)
.addToBackStack(WebsiteFragment.TAG)
.commit();
Fragment f = new WebsiteFragment();
String tag = WebsiteFragment.TAG;
showFragment(getParentFragmentManager(), f, tag);
});
}

View File

@@ -1,6 +1,9 @@
package org.briarproject.briar.android.hotspot;
import android.content.Context;
import android.content.Intent;
import android.content.pm.ApplicationInfo;
import android.content.pm.PackageManager;
import android.os.Bundle;
import android.view.LayoutInflater;
import android.view.View;
@@ -9,19 +12,27 @@ import android.widget.Button;
import android.widget.ProgressBar;
import android.widget.TextView;
import com.google.android.material.snackbar.Snackbar;
import org.briarproject.bramble.api.nullsafety.MethodsNotNullByDefault;
import org.briarproject.bramble.api.nullsafety.ParametersNotNullByDefault;
import org.briarproject.briar.R;
import javax.inject.Inject;
import androidx.activity.result.ActivityResultLauncher;
import androidx.activity.result.contract.ActivityResultContracts.RequestPermission;
import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult;
import androidx.annotation.Nullable;
import androidx.fragment.app.Fragment;
import androidx.fragment.app.FragmentActivity;
import androidx.lifecycle.ViewModelProvider;
import static android.content.pm.ApplicationInfo.FLAG_TEST_ONLY;
import static android.view.View.INVISIBLE;
import static android.view.View.VISIBLE;
import static androidx.transition.TransitionManager.beginDelayedTransition;
import static com.google.android.material.snackbar.BaseTransientBottomBar.LENGTH_LONG;
import static org.briarproject.briar.android.AppModule.getAndroidComponent;
@MethodsNotNullByDefault
@@ -34,13 +45,32 @@ public class HotspotIntroFragment extends Fragment {
ViewModelProvider.Factory viewModelFactory;
private HotspotViewModel viewModel;
private ConditionManager conditionManager;
private Button startButton;
private ProgressBar progressBar;
private TextView progressTextView;
private final ActivityResultLauncher<String> locationRequest =
registerForActivityResult(new RequestPermission(), granted -> {
conditionManager.onRequestPermissionResult(granted);
startHotspot();
});
private final ActivityResultLauncher<Intent> wifiRequest =
registerForActivityResult(new StartActivityForResult(), result -> {
conditionManager.onRequestWifiEnabledResult();
startHotspot();
});
@Override
public void onAttach(Context context) {
super.onAttach(context);
getAndroidComponent(requireContext()).inject(this);
viewModel = new ViewModelProvider(requireActivity(), viewModelFactory)
FragmentActivity activity = requireActivity();
getAndroidComponent(activity).inject(this);
viewModel = new ViewModelProvider(activity, viewModelFactory)
.get(HotspotViewModel.class);
conditionManager =
new ConditionManager(activity, locationRequest, wifiRequest);
}
@Override
@@ -50,31 +80,48 @@ public class HotspotIntroFragment extends Fragment {
View v = inflater
.inflate(R.layout.fragment_hotspot_intro, container, false);
Button startButton = v.findViewById(R.id.startButton);
ProgressBar progressBar = v.findViewById(R.id.progressBar);
TextView progressTextView = v.findViewById(R.id.progressTextView);
startButton = v.findViewById(R.id.startButton);
progressBar = v.findViewById(R.id.progressBar);
progressTextView = v.findViewById(R.id.progressTextView);
startButton.setOnClickListener(button -> {
beginDelayedTransition((ViewGroup) v);
startButton.setVisibility(INVISIBLE);
progressBar.setVisibility(VISIBLE);
progressTextView.setVisibility(VISIBLE);
// TODO remove below, tell viewModel to start hotspot instead
v.postDelayed(() -> {
viewModel.startHotspot();
getParentFragmentManager().beginTransaction()
.setCustomAnimations(R.anim.step_next_in,
R.anim.step_previous_out,
R.anim.step_previous_in,
R.anim.step_next_out)
.replace(R.id.fragmentContainer, new HotspotFragment(),
HotspotFragment.TAG)
.addToBackStack(HotspotFragment.TAG)
.commit();
}, 1500);
});
startButton.setOnClickListener(
button -> conditionManager.startConditionChecks());
return v;
}
@Override
public void onStart() {
super.onStart();
conditionManager.resetPermissions();
}
private void startHotspot() {
if (conditionManager.checkAndRequestConditions()) {
showInstallWarningIfNeeded();
beginDelayedTransition((ViewGroup) requireView());
startButton.setVisibility(INVISIBLE);
progressBar.setVisibility(VISIBLE);
progressTextView.setVisibility(VISIBLE);
viewModel.startHotspot();
}
}
private void showInstallWarningIfNeeded() {
Context ctx = requireContext();
ApplicationInfo applicationInfo;
try {
applicationInfo = ctx.getPackageManager()
.getApplicationInfo(ctx.getPackageName(), 0);
} catch (PackageManager.NameNotFoundException e) {
throw new AssertionError(e);
}
// test only apps can not be installed
if ((applicationInfo.flags & FLAG_TEST_ONLY) == FLAG_TEST_ONLY) {
int color = getResources().getColor(R.color.briar_red_500);
Snackbar.make(requireView(), R.string.hotspot_flag_test,
LENGTH_LONG).setBackgroundTint(color).show();
}
}
}

View File

@@ -0,0 +1,299 @@
package org.briarproject.briar.android.hotspot;
import android.content.Context;
import android.graphics.Bitmap;
import android.net.wifi.WifiManager;
import android.net.wifi.p2p.WifiP2pConfig;
import android.net.wifi.p2p.WifiP2pGroup;
import android.net.wifi.p2p.WifiP2pManager;
import android.net.wifi.p2p.WifiP2pManager.ActionListener;
import android.net.wifi.p2p.WifiP2pManager.GroupInfoListener;
import android.os.Handler;
import android.util.DisplayMetrics;
import org.briarproject.bramble.api.lifecycle.IoExecutor;
import org.briarproject.briar.R;
import org.briarproject.briar.android.hotspot.HotspotState.NetworkConfig;
import org.briarproject.briar.android.util.QrCodeUtils;
import java.security.SecureRandom;
import java.util.concurrent.Executor;
import java.util.logging.Logger;
import androidx.annotation.Nullable;
import androidx.annotation.RequiresApi;
import androidx.annotation.UiThread;
import static android.content.Context.WIFI_P2P_SERVICE;
import static android.content.Context.WIFI_SERVICE;
import static android.net.wifi.WifiManager.WIFI_MODE_FULL;
import static android.net.wifi.WifiManager.WIFI_MODE_FULL_HIGH_PERF;
import static android.net.wifi.p2p.WifiP2pConfig.GROUP_OWNER_BAND_2GHZ;
import static android.net.wifi.p2p.WifiP2pManager.BUSY;
import static android.net.wifi.p2p.WifiP2pManager.ERROR;
import static android.net.wifi.p2p.WifiP2pManager.NO_SERVICE_REQUESTS;
import static android.net.wifi.p2p.WifiP2pManager.P2P_UNSUPPORTED;
import static android.os.Build.VERSION.SDK_INT;
import static java.util.logging.Level.INFO;
import static java.util.logging.Logger.getLogger;
class HotspotManager implements ActionListener {
interface HotspotListener {
void onStartingHotspot();
@IoExecutor
void onHotspotStarted(NetworkConfig networkConfig);
void onHotspotStopped();
void onHotspotError(String error);
}
private static final Logger LOG = getLogger(HotspotManager.class.getName());
private static final int MAX_GROUP_INFO_ATTEMPTS = 5;
private static final int RETRY_DELAY_MILLIS = 1000;
private final Context ctx;
@IoExecutor
private final Executor ioExecutor;
private final SecureRandom random;
private final HotspotListener listener;
private final WifiManager wifiManager;
private final WifiP2pManager wifiP2pManager;
private final Handler handler;
private final String lockTag;
@Nullable
// on API < 29 this is null because we cannot request a custom network name
private String networkName = null;
private WifiManager.WifiLock wifiLock;
private WifiP2pManager.Channel channel;
HotspotManager(Context ctx, @IoExecutor Executor ioExecutor,
SecureRandom random, HotspotListener listener) {
this.ctx = ctx;
this.ioExecutor = ioExecutor;
this.random = random;
this.listener = listener;
wifiManager = (WifiManager) ctx.getApplicationContext()
.getSystemService(WIFI_SERVICE);
wifiP2pManager =
(WifiP2pManager) ctx.getSystemService(WIFI_P2P_SERVICE);
handler = new Handler(ctx.getMainLooper());
lockTag = ctx.getPackageName() + ":app-sharing-hotspot";
}
@UiThread
void startWifiP2pHotspot() {
if (wifiP2pManager == null) {
listener.onHotspotError(
ctx.getString(R.string.hotspot_error_no_wifi_direct));
return;
}
listener.onStartingHotspot();
channel = wifiP2pManager.initialize(ctx, ctx.getMainLooper(), null);
if (channel == null) {
listener.onHotspotError(
ctx.getString(R.string.hotspot_error_no_wifi_direct));
return;
}
acquireLock();
try {
if (SDK_INT >= 29) {
networkName = getNetworkName();
String passphrase = getPassphrase();
WifiP2pConfig config = new WifiP2pConfig.Builder()
.setGroupOperatingBand(GROUP_OWNER_BAND_2GHZ)
.setNetworkName(networkName)
.setPassphrase(passphrase)
.build();
wifiP2pManager.createGroup(channel, config, this);
} else {
wifiP2pManager.createGroup(channel, this);
}
} catch (SecurityException e) {
// this should never happen, because we request permissions before
throw new AssertionError(e);
}
}
@Override
// Callback for wifiP2pManager#createGroup() during startWifiP2pHotspot()
public void onSuccess() {
requestGroupInfo(1);
}
@Override
// Callback for wifiP2pManager#createGroup() during startWifiP2pHotspot()
public void onFailure(int reason) {
if (reason == BUSY) {
// Hotspot already running
requestGroupInfo(1);
} else if (reason == P2P_UNSUPPORTED) {
releaseHotspotWithError(ctx.getString(
R.string.hotspot_error_start_callback_failed,
"p2p unsupported"));
} else if (reason == ERROR) {
releaseHotspotWithError(ctx.getString(
R.string.hotspot_error_start_callback_failed, "p2p error"));
} else if (reason == NO_SERVICE_REQUESTS) {
releaseHotspotWithError(ctx.getString(
R.string.hotspot_error_start_callback_failed,
"no service requests"));
} else {
// all cases covered, in doubt set to error
releaseHotspotWithError(ctx.getString(
R.string.hotspot_error_start_callback_failed_unknown,
reason));
}
}
@RequiresApi(29)
private String getNetworkName() {
return "DIRECT-" + getRandomString(2) + "-" +
getRandomString(10);
}
private String getPassphrase() {
return getRandomString(8);
}
void stopWifiP2pHotspot() {
if (channel == null) return;
wifiP2pManager.removeGroup(channel, new ActionListener() {
@Override
public void onSuccess() {
releaseHotspot();
}
@Override
public void onFailure(int reason) {
// not propagating back error
releaseHotspot();
}
});
}
private void acquireLock() {
// WIFI_MODE_FULL has no effect on API >= 29
int lockType =
SDK_INT >= 29 ? WIFI_MODE_FULL_HIGH_PERF : WIFI_MODE_FULL;
wifiLock = wifiManager.createWifiLock(lockType, lockTag);
wifiLock.acquire();
}
private void releaseHotspot() {
listener.onHotspotStopped();
closeChannelAndReleaseLock();
}
private void releaseHotspotWithError(String error) {
listener.onHotspotError(error);
closeChannelAndReleaseLock();
}
private void closeChannelAndReleaseLock() {
if (SDK_INT >= 27) channel.close();
channel = null;
wifiLock.release();
}
private void requestGroupInfo(int attempt) {
if (LOG.isLoggable(INFO)) {
LOG.info("requestGroupInfo attempt: " + attempt);
}
GroupInfoListener groupListener = group -> {
boolean valid = isGroupValid(group);
// If the group is valid, set the hotspot to started. If we don't
// have any attempts left, we try what we got
if (valid || attempt >= MAX_GROUP_INFO_ATTEMPTS) {
onHotspotStarted(group);
} else {
retryRequestingGroupInfo(attempt + 1);
}
};
try {
wifiP2pManager.requestGroupInfo(channel, groupListener);
} catch (SecurityException e) {
// this should never happen, because we request permissions before
throw new AssertionError(e);
}
}
private void onHotspotStarted(WifiP2pGroup group) {
DisplayMetrics dm = ctx.getResources().getDisplayMetrics();
ioExecutor.execute(() -> {
String content = createWifiLoginString(group.getNetworkName(),
group.getPassphrase());
Bitmap qrCode = QrCodeUtils.createQrCode(dm, content);
NetworkConfig config = new NetworkConfig(group.getNetworkName(),
group.getPassphrase(), qrCode);
listener.onHotspotStarted(config);
});
}
private boolean isGroupValid(@Nullable WifiP2pGroup group) {
if (group == null) {
LOG.info("group is null");
return false;
} else if (!group.getNetworkName().startsWith("DIRECT-")) {
if (LOG.isLoggable(INFO)) {
LOG.info("received networkName without prefix 'DIRECT-': " +
group.getNetworkName());
}
return false;
} else if (networkName != null &&
!networkName.equals(group.getNetworkName())) {
if (LOG.isLoggable(INFO)) {
LOG.info("expected networkName: " + networkName);
LOG.info("received networkName: " + group.getNetworkName());
}
return false;
}
return true;
}
private void retryRequestingGroupInfo(int attempt) {
LOG.info("retrying");
// On some devices we need to wait for the group info to become available
if (attempt < MAX_GROUP_INFO_ATTEMPTS) {
handler.postDelayed(() -> requestGroupInfo(attempt + 1),
RETRY_DELAY_MILLIS);
} else {
releaseHotspotWithError(ctx.getString(
R.string.hotspot_error_start_callback_no_group_info));
}
}
private static String createWifiLoginString(String ssid, String password) {
// https://en.wikipedia.org/wiki/QR_code#WiFi_network_login
// do not remove the dangling ';', it can cause problems to omit it
return "WIFI:S:" + ssid + ";T:WPA;P:" + password + ";;";
}
private static final String digits = "123456789"; // avoid 0
private static final String letters = "abcdefghijkmnopqrstuvwxyz"; // no l
private static final String LETTERS = "ABCDEFGHJKLMNPQRSTUVWXYZ"; // no I, O
private String getRandomString(int length) {
char[] c = new char[length];
for (int i = 0; i < length; i++) {
if (random.nextBoolean()) {
c[i] = random(digits);
} else if (random.nextBoolean()) {
c[i] = random(letters);
} else {
c[i] = random(LETTERS);
}
}
return new String(c);
}
private char random(String universe) {
return universe.charAt(random.nextInt(universe.length()));
}
}

View File

@@ -0,0 +1,66 @@
package org.briarproject.briar.android.hotspot;
import android.graphics.Bitmap;
import androidx.annotation.Nullable;
abstract class HotspotState {
static class StartingHotspot extends HotspotState {
}
static class NetworkConfig {
final String ssid, password;
@Nullable
final Bitmap qrCode;
NetworkConfig(String ssid, String password, @Nullable Bitmap qrCode) {
this.ssid = ssid;
this.password = password;
this.qrCode = qrCode;
}
}
static class WebsiteConfig {
final String url;
@Nullable
final Bitmap qrCode;
WebsiteConfig(String url, @Nullable Bitmap qrCode) {
this.url = url;
this.qrCode = qrCode;
}
}
static class HotspotStarted extends HotspotState {
private final NetworkConfig networkConfig;
private final WebsiteConfig websiteConfig;
HotspotStarted(NetworkConfig networkConfig,
WebsiteConfig websiteConfig) {
this.networkConfig = networkConfig;
this.websiteConfig = websiteConfig;
}
NetworkConfig getNetworkConfig() {
return networkConfig;
}
WebsiteConfig getWebsiteConfig() {
return websiteConfig;
}
}
static class HotspotError extends HotspotState {
private final String error;
HotspotError(String error) {
this.error = error;
}
String getError() {
return error;
}
}
}

View File

@@ -4,37 +4,82 @@ import android.app.Application;
import org.briarproject.bramble.api.db.DatabaseExecutor;
import org.briarproject.bramble.api.db.TransactionManager;
import org.briarproject.bramble.api.lifecycle.IoExecutor;
import org.briarproject.bramble.api.lifecycle.LifecycleManager;
import org.briarproject.bramble.api.nullsafety.NotNullByDefault;
import org.briarproject.bramble.api.system.AndroidExecutor;
import org.briarproject.briar.R;
import org.briarproject.briar.android.hotspot.HotspotManager.HotspotListener;
import org.briarproject.briar.android.hotspot.HotspotState.HotspotError;
import org.briarproject.briar.android.hotspot.HotspotState.HotspotStarted;
import org.briarproject.briar.android.hotspot.HotspotState.NetworkConfig;
import org.briarproject.briar.android.hotspot.HotspotState.StartingHotspot;
import org.briarproject.briar.android.hotspot.HotspotState.WebsiteConfig;
import org.briarproject.briar.android.hotspot.WebServerManager.WebServerListener;
import org.briarproject.briar.android.viewmodel.DbViewModel;
import org.briarproject.briar.api.android.AndroidNotificationManager;
import java.security.SecureRandom;
import java.util.concurrent.Executor;
import java.util.logging.Logger;
import javax.inject.Inject;
@NotNullByDefault
class HotspotViewModel extends DbViewModel {
import androidx.annotation.Nullable;
import androidx.annotation.UiThread;
import androidx.lifecycle.LiveData;
import androidx.lifecycle.MutableLiveData;
import static java.util.logging.Logger.getLogger;
@NotNullByDefault
class HotspotViewModel extends DbViewModel
implements HotspotListener, WebServerListener {
private static final Logger LOG =
getLogger(HotspotViewModel.class.getName());
@IoExecutor
private final Executor ioExecutor;
private final AndroidNotificationManager notificationManager;
private final HotspotManager hotspotManager;
private final WebServerManager webServerManager;
private final MutableLiveData<HotspotState> state =
new MutableLiveData<>();
@Nullable
// Field to temporarily store the network config received via onHotspotStarted()
// in order to post it along with a HotspotStarted status
private volatile NetworkConfig networkConfig;
@Inject
HotspotViewModel(Application application,
HotspotViewModel(Application app,
@DatabaseExecutor Executor dbExecutor,
LifecycleManager lifecycleManager,
TransactionManager db,
AndroidExecutor androidExecutor,
@IoExecutor Executor ioExecutor,
SecureRandom secureRandom,
AndroidNotificationManager notificationManager) {
super(application, dbExecutor, lifecycleManager, db, androidExecutor);
super(app, dbExecutor, lifecycleManager, db, androidExecutor);
this.ioExecutor = ioExecutor;
this.notificationManager = notificationManager;
hotspotManager =
new HotspotManager(app, ioExecutor, secureRandom, this);
webServerManager = new WebServerManager(app, this);
}
@UiThread
void startHotspot() {
hotspotManager.startWifiP2pHotspot();
notificationManager.showHotspotNotification();
}
@UiThread
private void stopHotspot() {
ioExecutor.execute(webServerManager::stopWebServer);
hotspotManager.stopWifiP2pHotspot();
notificationManager.clearHotspotNotification();
}
@@ -44,6 +89,49 @@ class HotspotViewModel extends DbViewModel {
stopHotspot();
}
// TODO copy actual code from Offline Hotspot app
@Override
public void onStartingHotspot() {
state.setValue(new StartingHotspot());
}
@Override
@IoExecutor
public void onHotspotStarted(NetworkConfig networkConfig) {
this.networkConfig = networkConfig;
LOG.info("starting webserver");
webServerManager.startWebServer();
}
@Override
public void onHotspotStopped() {
LOG.info("stopping webserver");
ioExecutor.execute(webServerManager::stopWebServer);
}
@Override
public void onHotspotError(String error) {
state.setValue(new HotspotError(error));
ioExecutor.execute(webServerManager::stopWebServer);
notificationManager.clearHotspotNotification();
}
@Override
@IoExecutor
public void onWebServerStarted(WebsiteConfig websiteConfig) {
state.postValue(new HotspotStarted(networkConfig, websiteConfig));
networkConfig = null;
}
@Override
@IoExecutor
public void onWebServerError() {
state.postValue(new HotspotError(getApplication()
.getString(R.string.hotspot_error_web_server_start)));
hotspotManager.stopWifiP2pHotspot();
}
LiveData<HotspotState> getState() {
return state;
}
}

View File

@@ -14,12 +14,14 @@ import org.briarproject.briar.R;
import javax.inject.Inject;
import androidx.annotation.Nullable;
import androidx.core.util.Consumer;
import androidx.fragment.app.Fragment;
import androidx.lifecycle.ViewModelProvider;
import static android.view.View.GONE;
import static org.briarproject.briar.android.AppModule.getAndroidComponent;
import static org.briarproject.briar.android.hotspot.AbstractTabsFragment.ARG_FOR_WIFI_CONNECT;
import static org.briarproject.briar.android.hotspot.HotspotState.HotspotStarted;
@MethodsNotNullByDefault
@ParametersNotNullByDefault
@@ -66,21 +68,28 @@ public class ManualHotspotFragment extends Fragment {
TextView passwordView = v.findViewById(R.id.passwordView);
TextView altView = v.findViewById(R.id.altView);
Consumer<HotspotStarted> consumer;
if (requireArguments().getBoolean(ARG_FOR_WIFI_CONNECT)) {
manualIntroView.setText(R.string.hotspot_manual_wifi);
ssidLabelView.setText(R.string.hotspot_manual_wifi_ssid);
// TODO observe state in ViewModel and get info from there instead
ssidView.setText("DIRECT-42-dfzsgf34ef");
passwordView.setText("sdf78shfd8");
consumer = state -> {
ssidView.setText(state.getNetworkConfig().ssid);
passwordView.setText(state.getNetworkConfig().password);
};
altView.setText(R.string.hotspot_manual_wifi_alt);
} else {
manualIntroView.setText(R.string.hotspot_manual_site);
ssidLabelView.setText(R.string.hotspot_manual_site_address);
// TODO observe state in ViewModel and get info from there instead
ssidView.setText("http://192.168.49.1:9999");
consumer = state -> ssidView.setText(state.getWebsiteConfig().url);
altView.setText(R.string.hotspot_manual_site_alt);
v.findViewById(R.id.passwordLabelView).setVisibility(GONE);
passwordView.setVisibility(GONE);
}
viewModel.getState().observe(getViewLifecycleOwner(), state -> {
// we only expect to be in this state here
if (state instanceof HotspotStarted) {
consumer.accept((HotspotStarted) state);
}
});
}
}

View File

@@ -11,10 +11,12 @@ import android.widget.TextView;
import org.briarproject.bramble.api.nullsafety.MethodsNotNullByDefault;
import org.briarproject.bramble.api.nullsafety.ParametersNotNullByDefault;
import org.briarproject.briar.R;
import org.briarproject.briar.android.hotspot.HotspotState.HotspotStarted;
import javax.inject.Inject;
import androidx.annotation.Nullable;
import androidx.core.util.Consumer;
import androidx.fragment.app.Fragment;
import androidx.lifecycle.ViewModelProvider;
@@ -58,13 +60,21 @@ public class QrHotspotFragment extends Fragment {
TextView qrIntroView = v.findViewById(R.id.qrIntroView);
ImageView qrCodeView = v.findViewById(R.id.qrCodeView);
Consumer<HotspotStarted> consumer;
if (requireArguments().getBoolean(ARG_FOR_WIFI_CONNECT)) {
qrIntroView.setText(R.string.hotspot_qr_wifi);
// TODO observe state in ViewModel and get QR code from there
consumer = state ->
qrCodeView.setImageBitmap(state.getNetworkConfig().qrCode);
} else {
qrIntroView.setText(R.string.hotspot_qr_site);
// TODO observe state in ViewModel and get QR code from there
consumer = state ->
qrCodeView.setImageBitmap(state.getWebsiteConfig().qrCode);
}
viewModel.getState().observe(getViewLifecycleOwner(), state -> {
if (state instanceof HotspotStarted) {
consumer.accept((HotspotStarted) state);
}
});
return v;
}

View File

@@ -0,0 +1,130 @@
package org.briarproject.briar.android.hotspot;
import android.content.Context;
import org.briarproject.briar.R;
import org.jsoup.Jsoup;
import org.jsoup.nodes.Document;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.InputStream;
import java.util.logging.Logger;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import androidx.annotation.Nullable;
import fi.iki.elonen.NanoHTTPD;
import static android.util.Xml.Encoding.UTF_8;
import static fi.iki.elonen.NanoHTTPD.Response.Status.INTERNAL_ERROR;
import static fi.iki.elonen.NanoHTTPD.Response.Status.NOT_FOUND;
import static fi.iki.elonen.NanoHTTPD.Response.Status.OK;
import static java.util.Objects.requireNonNull;
import static java.util.logging.Level.WARNING;
import static java.util.logging.Logger.getLogger;
import static org.briarproject.bramble.util.LogUtils.logException;
import static org.briarproject.briar.BuildConfig.VERSION_NAME;
public class WebServer extends NanoHTTPD {
final static int PORT = 9999;
private static final Logger LOG = getLogger(WebServer.class.getName());
private static final String FILE_HTML = "hotspot.html";
private static final Pattern REGEX_AGENT =
Pattern.compile("Android ([0-9]+)");
private final Context ctx;
WebServer(Context ctx) {
super(PORT);
this.ctx = ctx;
}
@Override
public void start() throws IOException {
start(NanoHTTPD.SOCKET_READ_TIMEOUT, false);
}
@Override
public Response serve(IHTTPSession session) {
if (session.getUri().endsWith("favicon.ico")) {
return newFixedLengthResponse(NOT_FOUND, MIME_PLAINTEXT,
NOT_FOUND.getDescription());
}
if (session.getUri().endsWith(".apk")) {
return serveApk();
}
Response res;
try {
String html = getHtml(session.getHeaders().get("user-agent"));
res = newFixedLengthResponse(OK, MIME_HTML, html);
} catch (Exception e) {
logException(LOG, WARNING, e);
res = newFixedLengthResponse(INTERNAL_ERROR, MIME_PLAINTEXT,
INTERNAL_ERROR.getDescription());
}
return res;
}
private String getHtml(@Nullable String userAgent) throws Exception {
Document doc;
try (InputStream is = ctx.getAssets().open(FILE_HTML)) {
doc = Jsoup.parse(is, UTF_8.name(), "");
}
String app = ctx.getString(R.string.app_name);
String appV = app + " " + VERSION_NAME;
doc.select("#download_title").first()
.text(ctx.getString(R.string.website_download_title, appV));
doc.select("#download_intro").first()
.text(ctx.getString(R.string.website_download_intro, app));
doc.select("#download_button").first()
.text(ctx.getString(R.string.website_download_title, app));
doc.select("#download_outro").first()
.text(ctx.getString(R.string.website_download_outro));
doc.select("#troubleshooting_title").first()
.text(ctx.getString(R.string.website_troubleshooting_title));
doc.select("#troubleshooting_1").first()
.text(ctx.getString(R.string.website_troubleshooting_1));
doc.select("#troubleshooting_2").first()
.text(getUnknownSourcesString(userAgent));
return doc.outerHtml();
}
private String getUnknownSourcesString(String userAgent) {
boolean is8OrHigher = false;
if (userAgent != null) {
Matcher matcher = REGEX_AGENT.matcher(userAgent);
if (matcher.find()) {
int androidMajorVersion =
Integer.parseInt(requireNonNull(matcher.group(1)));
is8OrHigher = androidMajorVersion >= 8;
}
}
return is8OrHigher ?
ctx.getString(R.string.website_troubleshooting_2_new) :
ctx.getString(R.string.website_troubleshooting_2_old);
}
private Response serveApk() {
String mime = "application/vnd.android.package-archive";
File file = new File(ctx.getPackageCodePath());
long fileLen = file.length();
Response res;
try {
FileInputStream fis = new FileInputStream(file);
res = newFixedLengthResponse(OK, mime, fis, fileLen);
res.addHeader("Content-Length", "" + fileLen);
} catch (FileNotFoundException e) {
logException(LOG, WARNING, e);
res = newFixedLengthResponse(NOT_FOUND, MIME_PLAINTEXT,
"Error 404, file not found.");
}
return res;
}
}

View File

@@ -0,0 +1,114 @@
package org.briarproject.briar.android.hotspot;
import android.content.Context;
import android.graphics.Bitmap;
import android.util.DisplayMetrics;
import org.briarproject.bramble.api.lifecycle.IoExecutor;
import org.briarproject.briar.android.hotspot.HotspotState.WebsiteConfig;
import org.briarproject.briar.android.util.QrCodeUtils;
import java.io.IOException;
import java.net.InetAddress;
import java.net.InterfaceAddress;
import java.net.NetworkInterface;
import java.net.SocketException;
import java.util.Enumeration;
import java.util.List;
import java.util.logging.Logger;
import androidx.annotation.Nullable;
import static java.util.Collections.emptyList;
import static java.util.Collections.list;
import static java.util.logging.Level.INFO;
import static java.util.logging.Level.WARNING;
import static java.util.logging.Logger.getLogger;
import static org.briarproject.bramble.util.LogUtils.logException;
import static org.briarproject.briar.android.hotspot.WebServer.PORT;
class WebServerManager {
interface WebServerListener {
@IoExecutor
void onWebServerStarted(WebsiteConfig websiteConfig);
@IoExecutor
void onWebServerError();
}
private static final Logger LOG =
getLogger(WebServerManager.class.getName());
private final WebServer webServer;
private final WebServerListener listener;
private final DisplayMetrics dm;
WebServerManager(Context ctx, WebServerListener listener) {
this.listener = listener;
webServer = new WebServer(ctx);
dm = ctx.getResources().getDisplayMetrics();
}
@IoExecutor
void startWebServer() {
try {
webServer.start();
onWebServerStarted();
} catch (IOException e) {
logException(LOG, WARNING, e);
listener.onWebServerError();
}
}
@IoExecutor
private void onWebServerStarted() {
String url = "http://192.168.49.1:" + PORT;
InetAddress address = getAccessPointAddress();
if (address == null) {
LOG.info(
"Could not find access point address, assuming 192.168.49.1");
} else {
if (LOG.isLoggable(INFO)) {
LOG.info("Access point address " + address.getHostAddress());
}
url = "http://" + address.getHostAddress() + ":" + PORT;
}
Bitmap qrCode = QrCodeUtils.createQrCode(dm, url);
listener.onWebServerStarted(new WebsiteConfig(url, qrCode));
}
/**
* It is safe to call this more than once and it won't throw.
*/
@IoExecutor
void stopWebServer() {
webServer.stop();
}
@Nullable
private static InetAddress getAccessPointAddress() {
for (NetworkInterface i : getNetworkInterfaces()) {
if (i.getName().startsWith("p2p")) {
for (InterfaceAddress a : i.getInterfaceAddresses()) {
// we consider only IPv4 addresses
if (a.getAddress().getAddress().length == 4)
return a.getAddress();
}
}
}
return null;
}
private static List<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

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