diff --git a/bramble-api/src/main/java/org/briarproject/bramble/api/FeatureFlags.java b/bramble-api/src/main/java/org/briarproject/bramble/api/FeatureFlags.java index 0a3aefde4..ba06f2a47 100644 --- a/bramble-api/src/main/java/org/briarproject/bramble/api/FeatureFlags.java +++ b/bramble-api/src/main/java/org/briarproject/bramble/api/FeatureFlags.java @@ -14,4 +14,6 @@ public interface FeatureFlags { boolean shouldEnableConnectViaBluetooth(); boolean shouldEnableTransferData(); + + boolean shouldEnableShareAppViaOfflineHotspot(); } diff --git a/bramble-core/src/test/java/org/briarproject/bramble/test/TestFeatureFlagModule.java b/bramble-core/src/test/java/org/briarproject/bramble/test/TestFeatureFlagModule.java index 59ec15e23..10e0fc656 100644 --- a/bramble-core/src/test/java/org/briarproject/bramble/test/TestFeatureFlagModule.java +++ b/bramble-core/src/test/java/org/briarproject/bramble/test/TestFeatureFlagModule.java @@ -34,6 +34,11 @@ public class TestFeatureFlagModule { public boolean shouldEnableTransferData() { return true; } + + @Override + public boolean shouldEnableShareAppViaOfflineHotspot() { + return true; + } }; } } diff --git a/briar-android/build.gradle b/briar-android/build.gradle index 16b32a9de..9c15ccb17 100644 --- a/briar-android/build.gradle +++ b/briar-android/build.gradle @@ -121,6 +121,7 @@ dependencies { exclude group: 'com.android.support' exclude module: 'disklrucache' // when there's no disk cache, we can't accidentally use it } + implementation 'org.nanohttpd:nanohttpd:2.3.1' annotationProcessor "com.google.dagger:dagger-compiler:$dagger_version" annotationProcessor "com.github.bumptech.glide:compiler:$glideVersion" diff --git a/briar-android/src/main/AndroidManifest.xml b/briar-android/src/main/AndroidManifest.xml index e325e8aca..738ddbdf0 100644 --- a/briar-android/src/main/AndroidManifest.xml +++ b/briar-android/src/main/AndroidManifest.xml @@ -18,6 +18,7 @@ + @@ -456,6 +457,11 @@ android:label="@string/pending_contact_requests" android:theme="@style/BriarTheme" /> + + diff --git a/briar-android/src/main/assets/hotspot.html b/briar-android/src/main/assets/hotspot.html new file mode 100644 index 000000000..7d41e7122 --- /dev/null +++ b/briar-android/src/main/assets/hotspot.html @@ -0,0 +1,103 @@ + + + + + + + + +
+ + + + + + +

Download Briar 1.2.20

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

Troubleshooting

+
    +
  1. If you can't download the app, try it with a different web + browser app. +
  2. +
  3. To install the downloaded app, + you might need to allow your browser to install unknown apps. + We recommend to undo that after successful installation. +
  4. +
+
+ + + diff --git a/briar-android/src/main/java/org/briarproject/briar/android/AndroidComponent.java b/briar-android/src/main/java/org/briarproject/briar/android/AndroidComponent.java index 28f32379c..d2ecf5196 100644 --- a/briar-android/src/main/java/org/briarproject/briar/android/AndroidComponent.java +++ b/briar-android/src/main/java/org/briarproject/briar/android/AndroidComponent.java @@ -36,6 +36,11 @@ import org.briarproject.briar.BriarCoreModule; import org.briarproject.briar.android.attachment.AttachmentModule; import org.briarproject.briar.android.attachment.media.MediaModule; import org.briarproject.briar.android.conversation.glide.BriarModelLoader; +import org.briarproject.briar.android.hotspot.AbstractTabsFragment; +import org.briarproject.briar.android.hotspot.FallbackFragment; +import org.briarproject.briar.android.hotspot.HotspotIntroFragment; +import org.briarproject.briar.android.hotspot.ManualHotspotFragment; +import org.briarproject.briar.android.hotspot.QrHotspotFragment; import org.briarproject.briar.android.logging.CachingLogHandler; import org.briarproject.briar.android.login.SignInReminderReceiver; import org.briarproject.briar.android.removabledrive.ChooserFragment; @@ -216,6 +221,16 @@ public interface AndroidComponent void inject(NotificationsFragment notificationsFragment); + void inject(HotspotIntroFragment hotspotIntroFragment); + + void inject(AbstractTabsFragment abstractTabsFragment); + + void inject(QrHotspotFragment qrHotspotFragment); + + void inject(ManualHotspotFragment manualHotspotFragment); + + void inject(FallbackFragment fallbackFragment); + void inject(ChooserFragment chooserFragment); void inject(SendFragment sendFragment); diff --git a/briar-android/src/main/java/org/briarproject/briar/android/AndroidNotificationManagerImpl.java b/briar-android/src/main/java/org/briarproject/briar/android/AndroidNotificationManagerImpl.java index f7a175805..05135062e 100644 --- a/briar-android/src/main/java/org/briarproject/briar/android/AndroidNotificationManagerImpl.java +++ b/briar-android/src/main/java/org/briarproject/briar/android/AndroidNotificationManagerImpl.java @@ -30,6 +30,7 @@ import org.briarproject.bramble.util.StringUtils; import org.briarproject.briar.R; import org.briarproject.briar.android.conversation.ConversationActivity; import org.briarproject.briar.android.forum.ForumActivity; +import org.briarproject.briar.android.hotspot.HotspotActivity; import org.briarproject.briar.android.login.SignInReminderReceiver; import org.briarproject.briar.android.navdrawer.NavDrawerActivity; import org.briarproject.briar.android.privategroup.conversation.GroupActivity; @@ -63,9 +64,11 @@ import static android.app.Notification.DEFAULT_SOUND; import static android.app.Notification.DEFAULT_VIBRATE; import static android.app.NotificationManager.IMPORTANCE_DEFAULT; import static android.app.NotificationManager.IMPORTANCE_LOW; +import static android.app.PendingIntent.getActivity; import static android.content.Context.NOTIFICATION_SERVICE; import static android.content.Intent.FLAG_ACTIVITY_CLEAR_TOP; import static android.content.Intent.FLAG_ACTIVITY_NEW_TASK; +import static android.content.Intent.FLAG_ACTIVITY_SINGLE_TOP; import static android.os.Build.VERSION.SDK_INT; import static androidx.core.app.NotificationCompat.CATEGORY_MESSAGE; import static androidx.core.app.NotificationCompat.CATEGORY_SERVICE; @@ -274,7 +277,7 @@ class AndroidNotificationManagerImpl implements AndroidNotificationManager, b.setWhen(0); // Don't show the time b.setOngoing(true); Intent i = new Intent(appContext, SplashScreenActivity.class); - b.setContentIntent(PendingIntent.getActivity(appContext, 0, i, 0)); + b.setContentIntent(getActivity(appContext, 0, i, 0)); if (SDK_INT >= 21) { b.setCategory(CATEGORY_SERVICE); b.setVisibility(VISIBILITY_SECRET); @@ -619,13 +622,11 @@ class AndroidNotificationManagerImpl implements AndroidNotificationManager, public void showSignInNotification() { if (blockSignInReminder) return; if (SDK_INT >= 26) { - NotificationChannel channel = - new NotificationChannel(REMINDER_CHANNEL_ID, appContext - .getString( - R.string.reminder_notification_channel_title), - IMPORTANCE_LOW); - channel.setLockscreenVisibility( - NotificationCompat.VISIBILITY_SECRET); + NotificationChannel channel = new NotificationChannel( + REMINDER_CHANNEL_ID, appContext + .getString(R.string.reminder_notification_channel_title), + IMPORTANCE_LOW); + channel.setLockscreenVisibility(VISIBILITY_SECRET); notificationManager.createNotificationChannel(channel); } @@ -652,7 +653,7 @@ class AndroidNotificationManagerImpl implements AndroidNotificationManager, Intent i = new Intent(appContext, SplashScreenActivity.class); i.setFlags(FLAG_ACTIVITY_NEW_TASK | FLAG_ACTIVITY_CLEAR_TOP); - b.setContentIntent(PendingIntent.getActivity(appContext, 0, i, 0)); + b.setContentIntent(getActivity(appContext, 0, i, 0)); notificationManager.notify(REMINDER_NOTIFICATION_ID, b.build()); } @@ -720,4 +721,40 @@ class AndroidNotificationManagerImpl implements AndroidNotificationManager, public void unblockAllBlogPostNotifications() { androidExecutor.runOnUiThread((Runnable) () -> blockBlogs = false); } + + @Override + public void showHotspotNotification() { + if (SDK_INT >= 26) { + String channelTitle = appContext + .getString(R.string.hotspot_notification_channel_title); + NotificationChannel channel = new NotificationChannel( + HOTSPOT_CHANNEL_ID, channelTitle, IMPORTANCE_LOW); + channel.setLockscreenVisibility(VISIBILITY_SECRET); + notificationManager.createNotificationChannel(channel); + } + BriarNotificationBuilder b = + new BriarNotificationBuilder(appContext, HOTSPOT_CHANNEL_ID); + b.setSmallIcon(R.drawable.notification_hotspot); + b.setColorRes(R.color.briar_brand_green); + b.setContentTitle( + appContext.getText(R.string.hotspot_notification_title)); + b.setNotificationCategory(CATEGORY_SERVICE); + b.setOngoing(true); + b.setShowWhen(true); + + String actionTitle = + appContext.getString(R.string.hotspot_button_stop_sharing); + Intent i = new Intent(appContext, HotspotActivity.class); + i.addFlags(FLAG_ACTIVITY_SINGLE_TOP); + i.setAction(ACTION_STOP_HOTSPOT); + PendingIntent actionIntent = getActivity(appContext, 0, i, 0); + int icon = SDK_INT >= 21 ? R.drawable.ic_portable_wifi_off : 0; + b.addAction(icon, actionTitle, actionIntent); + notificationManager.notify(HOTSPOT_NOTIFICATION_ID, b.build()); + } + + @Override + public void clearHotspotNotification() { + notificationManager.cancel(HOTSPOT_NOTIFICATION_ID); + } } diff --git a/briar-android/src/main/java/org/briarproject/briar/android/AppModule.java b/briar-android/src/main/java/org/briarproject/briar/android/AppModule.java index cc3ca07f7..78b27dbce 100644 --- a/briar-android/src/main/java/org/briarproject/briar/android/AppModule.java +++ b/briar-android/src/main/java/org/briarproject/briar/android/AppModule.java @@ -36,6 +36,7 @@ import org.briarproject.briar.android.blog.BlogModule; import org.briarproject.briar.android.contact.ContactListModule; import org.briarproject.briar.android.contact.add.nearby.AddNearbyContactModule; import org.briarproject.briar.android.forum.ForumModule; +import org.briarproject.briar.android.hotspot.HotspotModule; import org.briarproject.briar.android.introduction.IntroductionModule; import org.briarproject.briar.android.logging.LoggingModule; import org.briarproject.briar.android.login.LoginModule; @@ -94,6 +95,7 @@ import static org.briarproject.briar.android.TestingConstants.IS_DEBUG_BUILD; GroupListModule.class, GroupConversationModule.class, SharingModule.class, + HotspotModule.class, TransferDataModule.class, }) public class AppModule { @@ -318,6 +320,11 @@ public class AppModule { public boolean shouldEnableTransferData() { return IS_DEBUG_BUILD; } + + @Override + public boolean shouldEnableShareAppViaOfflineHotspot() { + return IS_DEBUG_BUILD; + } }; } } diff --git a/briar-android/src/main/java/org/briarproject/briar/android/activity/ActivityComponent.java b/briar-android/src/main/java/org/briarproject/briar/android/activity/ActivityComponent.java index 14f9e0753..683b484e8 100644 --- a/briar-android/src/main/java/org/briarproject/briar/android/activity/ActivityComponent.java +++ b/briar-android/src/main/java/org/briarproject/briar/android/activity/ActivityComponent.java @@ -38,6 +38,7 @@ import org.briarproject.briar.android.forum.CreateForumActivity; import org.briarproject.briar.android.forum.ForumActivity; import org.briarproject.briar.android.forum.ForumListFragment; import org.briarproject.briar.android.fragment.ScreenFilterDialogFragment; +import org.briarproject.briar.android.hotspot.HotspotActivity; import org.briarproject.briar.android.introduction.ContactChooserFragment; import org.briarproject.briar.android.introduction.IntroductionActivity; import org.briarproject.briar.android.introduction.IntroductionMessageFragment; @@ -177,6 +178,8 @@ public interface ActivityComponent { void inject(CrashReportActivity crashReportActivity); + void inject(HotspotActivity hotspotActivity); + void inject(RemovableDriveActivity activity); // Fragments diff --git a/briar-android/src/main/java/org/briarproject/briar/android/activity/BaseActivity.java b/briar-android/src/main/java/org/briarproject/briar/android/activity/BaseActivity.java index da993afed..0c4761ada 100644 --- a/briar-android/src/main/java/org/briarproject/briar/android/activity/BaseActivity.java +++ b/briar-android/src/main/java/org/briarproject/briar/android/activity/BaseActivity.java @@ -46,6 +46,7 @@ import static java.util.logging.Level.INFO; import static java.util.logging.Logger.getLogger; import static org.briarproject.briar.android.TestingConstants.PREVENT_SCREENSHOTS; import static org.briarproject.briar.android.util.UiUtils.hideSoftKeyboard; +import static org.briarproject.briar.android.util.UiUtils.showFragment; /** * Warning: Some activities don't extend {@link BaseActivity}. @@ -177,13 +178,7 @@ public abstract class BaseActivity extends AppCompatActivity public void showNextFragment(BaseFragment f) { if (!getLifecycle().getCurrentState().isAtLeast(STARTED)) return; - getSupportFragmentManager().beginTransaction() - .setCustomAnimations(R.anim.step_next_in, - R.anim.step_previous_out, R.anim.step_previous_in, - R.anim.step_next_out) - .replace(R.id.fragmentContainer, f, f.getUniqueTag()) - .addToBackStack(f.getUniqueTag()) - .commit(); + showFragment(getSupportFragmentManager(), f, f.getUniqueTag()); } protected boolean isFragmentAdded(String fragmentTag) { diff --git a/briar-android/src/main/java/org/briarproject/briar/android/contact/add/nearby/AddNearbyContactViewModel.java b/briar-android/src/main/java/org/briarproject/briar/android/contact/add/nearby/AddNearbyContactViewModel.java index d6d225100..0cf7c82c5 100644 --- a/briar-android/src/main/java/org/briarproject/briar/android/contact/add/nearby/AddNearbyContactViewModel.java +++ b/briar-android/src/main/java/org/briarproject/briar/android/contact/add/nearby/AddNearbyContactViewModel.java @@ -53,6 +53,7 @@ import org.briarproject.briar.android.contact.add.nearby.AddContactState.Contact import org.briarproject.briar.android.contact.add.nearby.AddContactState.KeyAgreementListening; import org.briarproject.briar.android.contact.add.nearby.AddContactState.KeyAgreementStarted; import org.briarproject.briar.android.contact.add.nearby.AddContactState.KeyAgreementWaiting; +import org.briarproject.briar.android.util.QrCodeUtils; import org.briarproject.briar.android.viewmodel.LiveEvent; import org.briarproject.briar.android.viewmodel.MutableLiveEvent; diff --git a/briar-android/src/main/java/org/briarproject/briar/android/fragment/ErrorFragment.java b/briar-android/src/main/java/org/briarproject/briar/android/fragment/ErrorFragment.java index 3b8807f0d..c6e1d85f8 100644 --- a/briar-android/src/main/java/org/briarproject/briar/android/fragment/ErrorFragment.java +++ b/briar-android/src/main/java/org/briarproject/briar/android/fragment/ErrorFragment.java @@ -17,7 +17,7 @@ import androidx.annotation.Nullable; @ParametersNotNullByDefault public class ErrorFragment extends BaseFragment { - private static final String TAG = ErrorFragment.class.getName(); + public static final String TAG = ErrorFragment.class.getName(); private static final String ERROR_MSG = "errorMessage"; @@ -40,8 +40,7 @@ public class ErrorFragment extends BaseFragment { public void onCreate(@Nullable Bundle savedInstanceState) { super.onCreate(savedInstanceState); - Bundle args = getArguments(); - if (args == null) throw new AssertionError(); + Bundle args = requireArguments(); errorMessage = args.getString(ERROR_MSG); } diff --git a/briar-android/src/main/java/org/briarproject/briar/android/hotspot/AbstractConditionManager.java b/briar-android/src/main/java/org/briarproject/briar/android/hotspot/AbstractConditionManager.java new file mode 100644 index 000000000..855fd845d --- /dev/null +++ b/briar-android/src/main/java/org/briarproject/briar/android/hotspot/AbstractConditionManager.java @@ -0,0 +1,84 @@ +package org.briarproject.briar.android.hotspot; + +import android.content.Context; +import android.content.DialogInterface; +import android.net.wifi.WifiManager; + +import org.briarproject.briar.R; + +import androidx.annotation.StringRes; +import androidx.appcompat.app.AlertDialog; +import androidx.core.util.Consumer; +import androidx.fragment.app.FragmentActivity; + +import static android.content.Context.WIFI_SERVICE; + +/** + * Abstract base class for the ConditionManagers that ensure that the conditions + * to open a hotspot are fulfilled. There are different extensions of this for + * API levels lower than 29 and 29+. + */ +abstract class AbstractConditionManager { + + enum Permission { + UNKNOWN, GRANTED, SHOW_RATIONALE, PERMANENTLY_DENIED + } + + protected final Consumer permissionUpdateCallback; + protected FragmentActivity ctx; + WifiManager wifiManager; + + AbstractConditionManager(Consumer permissionUpdateCallback) { + this.permissionUpdateCallback = permissionUpdateCallback; + } + + /** + * Pass a FragmentActivity context here during `onCreateView()`. + */ + void init(FragmentActivity ctx) { + this.ctx = ctx; + this.wifiManager = (WifiManager) ctx.getApplicationContext() + .getSystemService(WIFI_SERVICE); + } + + /** + * Call this during onStart() in the fragment where the ConditionManager + * is used. + */ + abstract void onStart(); + + /** + * Check if all required conditions are met such that the hotspot can be + * started. If any precondition is not met yet, bring up relevant dialogs + * asking the user to grant relevant permissions or take relevant actions. + * + * @return true if conditions are fulfilled and flow can continue. + */ + abstract boolean checkAndRequestConditions(); + + void showDenialDialog(FragmentActivity ctx, + @StringRes int title, @StringRes int body, + DialogInterface.OnClickListener onOkClicked, Runnable onDismiss) { + AlertDialog.Builder builder = new AlertDialog.Builder(ctx); + builder.setTitle(title); + builder.setMessage(body); + builder.setPositiveButton(R.string.ok, onOkClicked); + builder.setNegativeButton(R.string.cancel, + (dialog, which) -> ctx.supportFinishAfterTransition()); + builder.setOnDismissListener(dialog -> onDismiss.run()); + builder.show(); + } + + void showRationale(Context ctx, @StringRes int title, + @StringRes int body, Runnable onContinueClicked, + Runnable onDismiss) { + AlertDialog.Builder builder = new AlertDialog.Builder(ctx); + builder.setTitle(title); + builder.setMessage(body); + builder.setNeutralButton(R.string.continue_button, + (dialog, which) -> onContinueClicked.run()); + builder.setOnDismissListener(dialog -> onDismiss.run()); + builder.show(); + } + +} diff --git a/briar-android/src/main/java/org/briarproject/briar/android/hotspot/AbstractTabsFragment.java b/briar-android/src/main/java/org/briarproject/briar/android/hotspot/AbstractTabsFragment.java new file mode 100644 index 000000000..a3b940734 --- /dev/null +++ b/briar-android/src/main/java/org/briarproject/briar/android/hotspot/AbstractTabsFragment.java @@ -0,0 +1,129 @@ +package org.briarproject.briar.android.hotspot; + +import android.content.Context; +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.MenuItem; +import android.view.View; +import android.view.ViewGroup; +import android.widget.Button; + +import com.google.android.material.tabs.TabLayout; +import com.google.android.material.tabs.TabLayoutMediator; + +import org.briarproject.bramble.api.nullsafety.MethodsNotNullByDefault; +import org.briarproject.bramble.api.nullsafety.ParametersNotNullByDefault; +import org.briarproject.briar.R; + +import javax.inject.Inject; + +import androidx.annotation.CallSuper; +import androidx.annotation.Nullable; +import androidx.fragment.app.Fragment; +import androidx.lifecycle.ViewModelProvider; +import androidx.viewpager2.adapter.FragmentStateAdapter; +import androidx.viewpager2.widget.ViewPager2; + +import static androidx.core.app.ActivityCompat.finishAfterTransition; +import static org.briarproject.briar.android.AppModule.getAndroidComponent; +import static org.briarproject.briar.android.util.UiUtils.showFragment; + +@MethodsNotNullByDefault +@ParametersNotNullByDefault +public abstract class AbstractTabsFragment extends Fragment { + + static String ARG_FOR_WIFI_CONNECT = "forWifiConnect"; + + @Inject + ViewModelProvider.Factory viewModelFactory; + + protected HotspotViewModel viewModel; + + protected Button stopButton; + protected Button connectedButton; + + @Override + public void onAttach(Context context) { + super.onAttach(context); + getAndroidComponent(requireContext()).inject(this); + viewModel = new ViewModelProvider(requireActivity(), viewModelFactory) + .get(HotspotViewModel.class); + } + + @Override + public View onCreateView(LayoutInflater inflater, + @Nullable ViewGroup container, + @Nullable Bundle savedInstanceState) { + setHasOptionsMenu(true); + return inflater + .inflate(R.layout.fragment_hotspot_tabs, container, false); + } + + @Override + @CallSuper + public void onViewCreated(View view, @Nullable Bundle savedInstanceState) { + TabAdapter tabAdapter = new TabAdapter(this); + ViewPager2 viewPager = view.findViewById(R.id.pager); + viewPager.setAdapter(tabAdapter); + TabLayout tabLayout = view.findViewById(R.id.tabLayout); + new TabLayoutMediator(tabLayout, viewPager, (tab, position) -> { + // tabs are set in XML, but are just dummies that don't get added + if (position == 0) { + tab.setText(R.string.hotspot_tab_manual); + tab.setIcon(R.drawable.forum_item_create_white); + } else if (position == 1) { + tab.setText(R.string.qr_code); + tab.setIcon(R.drawable.ic_qr_code); + } else throw new AssertionError(); + }).attach(); + + stopButton = view.findViewById(R.id.stopButton); + stopButton.setOnClickListener(v -> { + // also clears hotspot + finishAfterTransition(requireActivity()); + }); + connectedButton = view.findViewById(R.id.connectedButton); + } + + @Override + public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) { + inflater.inflate(R.menu.hotspot_help_action, menu); + super.onCreateOptionsMenu(menu, inflater); + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + if (item.getItemId() == R.id.action_help) { + Fragment f = new HotspotHelpFragment(); + String tag = HotspotHelpFragment.TAG; + showFragment(getParentFragmentManager(), f, tag); + return true; + } + return super.onOptionsItemSelected(item); + } + + protected abstract Fragment getFirstFragment(); + + protected abstract Fragment getSecondFragment(); + + private class TabAdapter extends FragmentStateAdapter { + private TabAdapter(Fragment fragment) { + super(fragment); + } + + @Override + public Fragment createFragment(int position) { + if (position == 0) return getFirstFragment(); + if (position == 1) return getSecondFragment(); + throw new AssertionError(); + } + + @Override + public int getItemCount() { + return 2; + } + } + +} diff --git a/briar-android/src/main/java/org/briarproject/briar/android/hotspot/ConditionManager.java b/briar-android/src/main/java/org/briarproject/briar/android/hotspot/ConditionManager.java new file mode 100644 index 000000000..95c0d57fe --- /dev/null +++ b/briar-android/src/main/java/org/briarproject/briar/android/hotspot/ConditionManager.java @@ -0,0 +1,83 @@ +package org.briarproject.briar.android.hotspot; + +import android.content.Intent; +import android.provider.Settings; + +import org.briarproject.briar.R; + +import java.util.logging.Logger; + +import androidx.activity.result.ActivityResultCaller; +import androidx.activity.result.ActivityResultLauncher; +import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult; +import androidx.core.util.Consumer; + +import static java.util.logging.Level.INFO; +import static java.util.logging.Logger.getLogger; + +/** + * This class ensures that the conditions to open a hotspot are fulfilled on + * API levels < 29. + *

+ * As soon as {@link #checkAndRequestConditions()} returns true, + * all conditions are fulfilled. + */ +class ConditionManager extends AbstractConditionManager { + + private static final Logger LOG = + getLogger(ConditionManager.class.getName()); + + private final ActivityResultLauncher wifiRequest; + + ConditionManager(ActivityResultCaller arc, + Consumer permissionUpdateCallback) { + super(permissionUpdateCallback); + wifiRequest = arc.registerForActivityResult( + new StartActivityForResult(), + result -> permissionUpdateCallback + .accept(wifiManager.isWifiEnabled())); + } + + @Override + void onStart() { + // nothing to do here + } + + private boolean areEssentialPermissionsGranted() { + if (LOG.isLoggable(INFO)) { + LOG.info(String.format("areEssentialPermissionsGranted(): " + + "wifiManager.isWifiEnabled()? %b", + wifiManager.isWifiEnabled())); + } + return wifiManager.isWifiEnabled(); + } + + @Override + boolean checkAndRequestConditions() { + if (areEssentialPermissionsGranted()) return true; + + if (!wifiManager.isWifiEnabled()) { + // Try enabling the Wifi and return true if that seems to have been + // successful, i.e. "Wifi is either already in the requested state, or + // in progress toward the requested state". + if (wifiManager.setWifiEnabled(true)) { + LOG.info("Enabled wifi"); + return true; + } + + // Wifi is not enabled and we can't seem to enable it, so ask the user + // to enable it for us. + showRationale(ctx, R.string.wifi_settings_title, + R.string.wifi_settings_request_enable_body, + this::requestEnableWiFi, + () -> permissionUpdateCallback.accept(false)); + } + + return false; + } + + private void requestEnableWiFi() { + wifiRequest.launch(new Intent(Settings.ACTION_WIFI_SETTINGS)); + } + +} diff --git a/briar-android/src/main/java/org/briarproject/briar/android/hotspot/ConditionManager29.java b/briar-android/src/main/java/org/briarproject/briar/android/hotspot/ConditionManager29.java new file mode 100644 index 000000000..036225899 --- /dev/null +++ b/briar-android/src/main/java/org/briarproject/briar/android/hotspot/ConditionManager29.java @@ -0,0 +1,136 @@ +package org.briarproject.briar.android.hotspot; + +import android.content.Intent; +import android.provider.Settings; + +import org.briarproject.briar.R; + +import java.util.logging.Logger; + +import androidx.activity.result.ActivityResultCaller; +import androidx.activity.result.ActivityResultLauncher; +import androidx.activity.result.contract.ActivityResultContracts.RequestPermission; +import androidx.activity.result.contract.ActivityResultContracts.StartActivityForResult; +import androidx.annotation.Nullable; +import androidx.annotation.RequiresApi; +import androidx.core.util.Consumer; + +import static android.Manifest.permission.ACCESS_FINE_LOCATION; +import static androidx.core.app.ActivityCompat.shouldShowRequestPermissionRationale; +import static java.lang.Boolean.TRUE; +import static java.util.logging.Level.INFO; +import static java.util.logging.Logger.getLogger; +import static org.briarproject.briar.android.util.UiUtils.getGoToSettingsListener; + +/** + * This class ensures that the conditions to open a hotspot are fulfilled on + * API levels >= 29. + *

+ * As soon as {@link #checkAndRequestConditions()} returns true, + * all conditions are fulfilled. + */ +@RequiresApi(29) +class ConditionManager29 extends AbstractConditionManager { + + private static final Logger LOG = + getLogger(ConditionManager29.class.getName()); + + private Permission locationPermission = Permission.UNKNOWN; + + private final ActivityResultLauncher locationRequest; + private final ActivityResultLauncher wifiRequest; + + ConditionManager29(ActivityResultCaller arc, + Consumer permissionUpdateCallback) { + super(permissionUpdateCallback); + locationRequest = arc.registerForActivityResult( + new RequestPermission(), granted -> { + onRequestPermissionResult(granted); + permissionUpdateCallback.accept(TRUE.equals(granted)); + }); + wifiRequest = arc.registerForActivityResult( + new StartActivityForResult(), + result -> permissionUpdateCallback + .accept(wifiManager.isWifiEnabled()) + ); + } + + @Override + void onStart() { + locationPermission = Permission.UNKNOWN; + } + + private boolean areEssentialPermissionsGranted() { + boolean isWifiEnabled = wifiManager.isWifiEnabled(); + if (LOG.isLoggable(INFO)) { + LOG.info(String.format("areEssentialPermissionsGranted(): " + + "locationPermission? %s, " + + "wifiManager.isWifiEnabled()? %b", + locationPermission, isWifiEnabled)); + } + return locationPermission == Permission.GRANTED && isWifiEnabled; + } + + @Override + boolean checkAndRequestConditions() { + if (areEssentialPermissionsGranted()) return true; + + if (locationPermission == Permission.UNKNOWN) { + locationRequest.launch(ACCESS_FINE_LOCATION); + return false; + } + + // If the location permission has been permanently denied, ask the + // user to change the setting + if (locationPermission == Permission.PERMANENTLY_DENIED) { + showDenialDialog(ctx, R.string.permission_location_title, + R.string.permission_hotspot_location_denied_body, + getGoToSettingsListener(ctx), + () -> permissionUpdateCallback.accept(false)); + return false; + } + + // Should we show the rationale for location permission? + if (locationPermission == Permission.SHOW_RATIONALE) { + showRationale(ctx, R.string.permission_location_title, + R.string.permission_hotspot_location_request_body, + this::requestPermissions, + () -> permissionUpdateCallback.accept(false)); + return false; + } + + // If Wifi is not enabled, we show the rationale for enabling Wifi? + if (!wifiManager.isWifiEnabled()) { + showRationale(ctx, R.string.wifi_settings_title, + R.string.wifi_settings_request_enable_body, + this::requestEnableWiFi, + () -> permissionUpdateCallback.accept(false)); + return false; + } + + // we shouldn't usually reach this point, but if we do, return false + // anyway to force a recheck. Maybe some condition changed in the + // meantime. + return false; + } + + private void onRequestPermissionResult(@Nullable Boolean granted) { + if (granted != null && granted) { + locationPermission = Permission.GRANTED; + } else if (shouldShowRequestPermissionRationale(ctx, + ACCESS_FINE_LOCATION)) { + locationPermission = Permission.SHOW_RATIONALE; + } else { + locationPermission = Permission.PERMANENTLY_DENIED; + } + } + + private void requestPermissions() { + locationRequest.launch(ACCESS_FINE_LOCATION); + } + + private void requestEnableWiFi() { + wifiRequest.launch(new Intent(Settings.Panel.ACTION_WIFI)); + } + +} diff --git a/briar-android/src/main/java/org/briarproject/briar/android/hotspot/FallbackFragment.java b/briar-android/src/main/java/org/briarproject/briar/android/hotspot/FallbackFragment.java new file mode 100644 index 000000000..56c88f061 --- /dev/null +++ b/briar-android/src/main/java/org/briarproject/briar/android/hotspot/FallbackFragment.java @@ -0,0 +1,127 @@ +package org.briarproject.briar.android.hotspot; + +import android.content.Context; +import android.content.Intent; +import android.content.pm.ResolveInfo; +import android.net.Uri; +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.Button; +import android.widget.ProgressBar; + +import org.briarproject.bramble.api.nullsafety.MethodsNotNullByDefault; +import org.briarproject.bramble.api.nullsafety.ParametersNotNullByDefault; +import org.briarproject.briar.R; +import org.briarproject.briar.android.fragment.BaseFragment; +import org.briarproject.briar.android.util.ActivityLaunchers.CreateDocumentAdvanced; + +import java.util.List; + +import javax.inject.Inject; + +import androidx.activity.result.ActivityResultLauncher; +import androidx.annotation.Nullable; +import androidx.fragment.app.FragmentActivity; +import androidx.lifecycle.ViewModelProvider; + +import static android.content.Intent.ACTION_SEND; +import static android.content.Intent.EXTRA_STREAM; +import static android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION; +import static android.content.pm.PackageManager.MATCH_DEFAULT_ONLY; +import static android.os.Build.VERSION.SDK_INT; +import static android.view.View.INVISIBLE; +import static android.view.View.VISIBLE; +import static androidx.transition.TransitionManager.beginDelayedTransition; +import static org.briarproject.briar.android.AppModule.getAndroidComponent; +import static org.briarproject.briar.android.hotspot.HotspotViewModel.getApkFileName; + +@MethodsNotNullByDefault +@ParametersNotNullByDefault +public class FallbackFragment extends BaseFragment { + + public static final String TAG = FallbackFragment.class.getName(); + + @Inject + ViewModelProvider.Factory viewModelFactory; + + private HotspotViewModel viewModel; + private final ActivityResultLauncher launcher = + registerForActivityResult(new CreateDocumentAdvanced(), + this::onDocumentCreated); + private Button fallbackButton; + private ProgressBar progressBar; + + @Override + public String getUniqueTag() { + return TAG; + } + + @Override + public void onAttach(Context context) { + super.onAttach(context); + FragmentActivity activity = requireActivity(); + getAndroidComponent(activity).inject(this); + viewModel = new ViewModelProvider(activity, viewModelFactory) + .get(HotspotViewModel.class); + } + + @Nullable + @Override + public View onCreateView(LayoutInflater inflater, + @Nullable ViewGroup container, + @Nullable Bundle savedInstanceState) { + return inflater + .inflate(R.layout.fragment_hotspot_fallback, container, false); + } + + @Override + public void onViewCreated(View v, @Nullable Bundle savedInstanceState) { + super.onViewCreated(v, savedInstanceState); + + fallbackButton = v.findViewById(R.id.fallbackButton); + progressBar = v.findViewById(R.id.progressBar); + fallbackButton.setOnClickListener(view -> { + beginDelayedTransition((ViewGroup) v); + fallbackButton.setVisibility(INVISIBLE); + progressBar.setVisibility(VISIBLE); + + if (SDK_INT >= 19) launcher.launch(getApkFileName()); + else viewModel.exportApk(); + }); + viewModel.getSavedApkToUri().observeEvent(this, this::shareUri); + } + + private void onDocumentCreated(@Nullable Uri uri) { + showButton(); + if (uri != null) viewModel.exportApk(uri); + } + + private void showButton() { + beginDelayedTransition((ViewGroup) requireView()); + fallbackButton.setVisibility(VISIBLE); + progressBar.setVisibility(INVISIBLE); + } + + void shareUri(Uri uri) { + Intent i = new Intent(ACTION_SEND); + i.putExtra(EXTRA_STREAM, uri); + i.setType("*/*"); // gives us all sharing options + i.addFlags(FLAG_GRANT_READ_URI_PERMISSION); + Context ctx = requireContext(); + if (SDK_INT <= 19) { + // Workaround for Android bug: + // ctx.grantUriPermission also needed for Android 4 + List resInfoList = ctx.getPackageManager() + .queryIntentActivities(i, MATCH_DEFAULT_ONLY); + for (ResolveInfo resolveInfo : resInfoList) { + String packageName = resolveInfo.activityInfo.packageName; + ctx.grantUriPermission(packageName, uri, + FLAG_GRANT_READ_URI_PERMISSION); + } + } + startActivity(Intent.createChooser(i, null)); + } + +} diff --git a/briar-android/src/main/java/org/briarproject/briar/android/hotspot/HotspotActivity.java b/briar-android/src/main/java/org/briarproject/briar/android/hotspot/HotspotActivity.java new file mode 100644 index 000000000..359b86f2d --- /dev/null +++ b/briar-android/src/main/java/org/briarproject/briar/android/hotspot/HotspotActivity.java @@ -0,0 +1,143 @@ +package org.briarproject.briar.android.hotspot; + +import android.content.Intent; +import android.os.Bundle; +import android.view.MenuItem; + +import org.briarproject.bramble.api.nullsafety.MethodsNotNullByDefault; +import org.briarproject.bramble.api.nullsafety.ParametersNotNullByDefault; +import org.briarproject.briar.R; +import org.briarproject.briar.android.activity.ActivityComponent; +import org.briarproject.briar.android.activity.BriarActivity; +import org.briarproject.briar.android.fragment.BaseFragment.BaseFragmentListener; +import org.briarproject.briar.android.hotspot.HotspotState.HotspotError; +import org.briarproject.briar.android.hotspot.HotspotState.HotspotStarted; + +import javax.annotation.Nullable; +import javax.inject.Inject; + +import androidx.appcompat.app.ActionBar; +import androidx.fragment.app.Fragment; +import androidx.fragment.app.FragmentManager; +import androidx.lifecycle.ViewModelProvider; + +import static org.briarproject.briar.android.util.UiUtils.showFragment; +import static org.briarproject.briar.api.android.AndroidNotificationManager.ACTION_STOP_HOTSPOT; + +@MethodsNotNullByDefault +@ParametersNotNullByDefault +public class HotspotActivity extends BriarActivity + implements BaseFragmentListener { + + @Inject + ViewModelProvider.Factory viewModelFactory; + + private HotspotViewModel viewModel; + + @Override + public void injectActivity(ActivityComponent component) { + component.inject(this); + viewModel = new ViewModelProvider(this, viewModelFactory) + .get(HotspotViewModel.class); + } + + @Override + public void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.activity_fragment_container); + + ActionBar ab = getSupportActionBar(); + if (ab != null) { + ab.setDisplayHomeAsUpEnabled(true); + } + + FragmentManager fm = getSupportFragmentManager(); + viewModel.getState().observe(this, hotspotState -> { + if (hotspotState instanceof HotspotStarted) { + HotspotStarted started = (HotspotStarted) hotspotState; + String tag = HotspotFragment.TAG; + // check if fragment is already added + // to not lose state on configuration changes + if (fm.findFragmentByTag(tag) == null) { + if (started.wasNotYetConsumed()) { + started.consume(); + showFragment(fm, new HotspotFragment(), tag); + } + } + } else if (hotspotState instanceof HotspotError) { + HotspotError error = (HotspotError) hotspotState; + showErrorFragment(error.getError()); + } + }); + + if (savedInstanceState == null) { + // If there is no saved instance state, just start with the intro fragment. + fm.beginTransaction() + .replace(R.id.fragmentContainer, new HotspotIntroFragment(), + HotspotIntroFragment.TAG) + .commit(); + } else if (viewModel.getState().getValue() == null) { + // If there is saved instance state, then there's either been an + // configuration change like rotated device or the activity has been + // destroyed and is now being re-created. + // In the latter case, the view model will have been destroyed, too. + // The activity can only have been destroyed if the user navigated + // away from the HotspotActivity which is nothing we + // intend to support, so we want to detect that and start from scratch + // in this case. We need to clean up existing fragments in order not + // to stack new fragments on top of old ones. + + // If it is a configuration change and we moved past the intro + // fragment already, then the view model state will be != null, + // hence we can use this check for null to determine the destroyed + // activity. It can also be null if the user has not pressed + // "start sharing" yet, but in that case it won't harm to start from + // scratch. + + Fragment current = fm.findFragmentById(R.id.fragmentContainer); + if (current instanceof HotspotIntroFragment) { + // If the currently displayed fragment is the intro fragment, + // there's nothing we need to do. + return; + } + + // Remove everything from the back stack. + fm.popBackStackImmediate(null, + FragmentManager.POP_BACK_STACK_INCLUSIVE); + + // Start fresh with the intro fragment. + fm.beginTransaction() + .replace(R.id.fragmentContainer, new HotspotIntroFragment(), + HotspotIntroFragment.TAG) + .commit(); + } + } + + private void showErrorFragment(String error) { + FragmentManager fm = getSupportFragmentManager(); + String tag = HotspotErrorFragment.TAG; + if (fm.findFragmentByTag(tag) == null) { + Fragment f = HotspotErrorFragment.newInstance(error); + showFragment(fm, f, tag, false); + } + } + + @Override + public boolean onOptionsItemSelected(MenuItem item) { + if (item.getItemId() == android.R.id.home) { + onBackPressed(); + return true; + } + return super.onOptionsItemSelected(item); + } + + @Override + protected void onNewIntent(Intent intent) { + super.onNewIntent(intent); + if (ACTION_STOP_HOTSPOT.equals(intent.getAction())) { + // also closes hotspot + supportFinishAfterTransition(); + } + } + +} diff --git a/briar-android/src/main/java/org/briarproject/briar/android/hotspot/HotspotErrorFragment.java b/briar-android/src/main/java/org/briarproject/briar/android/hotspot/HotspotErrorFragment.java new file mode 100644 index 000000000..eb0b73165 --- /dev/null +++ b/briar-android/src/main/java/org/briarproject/briar/android/hotspot/HotspotErrorFragment.java @@ -0,0 +1,78 @@ +package org.briarproject.briar.android.hotspot; + +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.Button; +import android.widget.TextView; + +import org.briarproject.bramble.api.nullsafety.MethodsNotNullByDefault; +import org.briarproject.bramble.api.nullsafety.ParametersNotNullByDefault; +import org.briarproject.briar.R; +import org.briarproject.briar.android.fragment.BaseFragment; + +import javax.inject.Inject; + +import androidx.annotation.Nullable; +import androidx.lifecycle.ViewModelProvider; + +import static org.briarproject.briar.android.util.UiUtils.triggerFeedback; + + +@MethodsNotNullByDefault +@ParametersNotNullByDefault +public class HotspotErrorFragment extends BaseFragment { + + public static final String TAG = HotspotErrorFragment.class.getName(); + + @Inject + ViewModelProvider.Factory viewModelFactory; + + private static final String ERROR_MSG = "errorMessage"; + + public static HotspotErrorFragment newInstance(String message) { + HotspotErrorFragment f = new HotspotErrorFragment(); + Bundle args = new Bundle(); + args.putString(ERROR_MSG, message); + f.setArguments(args); + return f; + } + + private String errorMessage; + + @Override + public String getUniqueTag() { + return TAG; + } + + @Override + public void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + Bundle args = requireArguments(); + errorMessage = args.getString(ERROR_MSG); + } + + @Nullable + @Override + public View onCreateView(LayoutInflater inflater, + @Nullable ViewGroup container, + @Nullable Bundle savedInstanceState) { + requireActivity().setTitle(R.string.error); + return inflater + .inflate(R.layout.fragment_hotspot_error, container, false); + } + + @Override + public void onViewCreated(View v, @Nullable Bundle savedInstanceState) { + super.onViewCreated(v, savedInstanceState); + TextView msg = v.findViewById(R.id.errorMessageDetail); + msg.setText(errorMessage); + + Button feedbackButton = v.findViewById(R.id.feedbackButton); + feedbackButton.setOnClickListener( + button -> triggerFeedback(requireContext(), errorMessage)); + } + +} diff --git a/briar-android/src/main/java/org/briarproject/briar/android/hotspot/HotspotFragment.java b/briar-android/src/main/java/org/briarproject/briar/android/hotspot/HotspotFragment.java new file mode 100644 index 000000000..773faf510 --- /dev/null +++ b/briar-android/src/main/java/org/briarproject/briar/android/hotspot/HotspotFragment.java @@ -0,0 +1,52 @@ +package org.briarproject.briar.android.hotspot; + +import android.os.Bundle; +import android.view.View; +import android.widget.Toast; + +import org.briarproject.bramble.api.nullsafety.MethodsNotNullByDefault; +import org.briarproject.bramble.api.nullsafety.ParametersNotNullByDefault; +import org.briarproject.briar.R; + +import androidx.annotation.Nullable; +import androidx.fragment.app.Fragment; + +import static org.briarproject.briar.android.util.UiUtils.showFragment; + +@MethodsNotNullByDefault +@ParametersNotNullByDefault +public class HotspotFragment extends AbstractTabsFragment { + + public final static String TAG = HotspotFragment.class.getName(); + + @Override + public void onViewCreated(View view, @Nullable Bundle savedInstanceState) { + super.onViewCreated(view, savedInstanceState); + connectedButton.setOnClickListener(v -> showNextFragment()); + viewModel.getPeerConnectedEvent().observeEvent(getViewLifecycleOwner(), + this::onPeerConnected); + } + + @Override + protected Fragment getFirstFragment() { + return ManualHotspotFragment.newInstance(true); + } + + @Override + protected Fragment getSecondFragment() { + return QrHotspotFragment.newInstance(true); + } + + private void onPeerConnected(boolean connected) { + if (!connected) return; + Toast.makeText(requireContext(), R.string.hotspot_peer_connected, + Toast.LENGTH_LONG).show(); + } + + private void showNextFragment() { + Fragment f = new WebsiteFragment(); + String tag = WebsiteFragment.TAG; + showFragment(getParentFragmentManager(), f, tag); + } + +} diff --git a/briar-android/src/main/java/org/briarproject/briar/android/hotspot/HotspotHelpFragment.java b/briar-android/src/main/java/org/briarproject/briar/android/hotspot/HotspotHelpFragment.java new file mode 100644 index 000000000..aef52c5ac --- /dev/null +++ b/briar-android/src/main/java/org/briarproject/briar/android/hotspot/HotspotHelpFragment.java @@ -0,0 +1,29 @@ +package org.briarproject.briar.android.hotspot; + +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; + +import org.briarproject.bramble.api.nullsafety.MethodsNotNullByDefault; +import org.briarproject.bramble.api.nullsafety.ParametersNotNullByDefault; +import org.briarproject.briar.R; + +import androidx.annotation.Nullable; +import androidx.fragment.app.Fragment; + +@MethodsNotNullByDefault +@ParametersNotNullByDefault +public class HotspotHelpFragment extends Fragment { + + public final static String TAG = HotspotHelpFragment.class.getName(); + + @Override + public View onCreateView(LayoutInflater inflater, + @Nullable ViewGroup container, + @Nullable Bundle savedInstanceState) { + return inflater + .inflate(R.layout.fragment_hotspot_help, container, false); + } + +} diff --git a/briar-android/src/main/java/org/briarproject/briar/android/hotspot/HotspotIntroFragment.java b/briar-android/src/main/java/org/briarproject/briar/android/hotspot/HotspotIntroFragment.java new file mode 100644 index 000000000..fd6112bd0 --- /dev/null +++ b/briar-android/src/main/java/org/briarproject/briar/android/hotspot/HotspotIntroFragment.java @@ -0,0 +1,127 @@ +package org.briarproject.briar.android.hotspot; + +import android.content.Context; +import android.content.pm.ApplicationInfo; +import android.content.pm.PackageManager; +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.Button; +import android.widget.ProgressBar; +import android.widget.TextView; + +import com.google.android.material.snackbar.Snackbar; + +import org.briarproject.bramble.api.nullsafety.MethodsNotNullByDefault; +import org.briarproject.bramble.api.nullsafety.ParametersNotNullByDefault; +import org.briarproject.briar.R; + +import javax.inject.Inject; + +import androidx.annotation.Nullable; +import androidx.fragment.app.Fragment; +import androidx.fragment.app.FragmentActivity; +import androidx.lifecycle.ViewModelProvider; + +import static android.content.pm.ApplicationInfo.FLAG_TEST_ONLY; +import static android.os.Build.VERSION.SDK_INT; +import static android.view.View.INVISIBLE; +import static android.view.View.VISIBLE; +import static androidx.transition.TransitionManager.beginDelayedTransition; +import static com.google.android.material.snackbar.BaseTransientBottomBar.LENGTH_LONG; +import static org.briarproject.briar.android.AppModule.getAndroidComponent; + +@MethodsNotNullByDefault +@ParametersNotNullByDefault +public class HotspotIntroFragment extends Fragment { + + public final static String TAG = HotspotIntroFragment.class.getName(); + + @Inject + ViewModelProvider.Factory viewModelFactory; + + private HotspotViewModel viewModel; + + private Button startButton; + private ProgressBar progressBar; + private TextView progressTextView; + + private final AbstractConditionManager conditionManager = SDK_INT < 29 ? + new ConditionManager(this, this::onPermissionUpdate) : + new ConditionManager29(this, this::onPermissionUpdate); + + @Override + public void onAttach(Context context) { + super.onAttach(context); + FragmentActivity activity = requireActivity(); + getAndroidComponent(activity).inject(this); + viewModel = new ViewModelProvider(activity, viewModelFactory) + .get(HotspotViewModel.class); + } + + @Override + public View onCreateView(LayoutInflater inflater, + @Nullable ViewGroup container, + @Nullable Bundle savedInstanceState) { + View v = inflater + .inflate(R.layout.fragment_hotspot_intro, container, false); + + startButton = v.findViewById(R.id.startButton); + progressBar = v.findViewById(R.id.progressBar); + progressTextView = v.findViewById(R.id.progressTextView); + + startButton.setOnClickListener(this::onButtonClick); + + conditionManager.init(requireActivity()); + + return v; + } + + @Override + public void onStart() { + super.onStart(); + conditionManager.onStart(); + } + + private void onButtonClick(View view) { + startButton.setEnabled(false); + startHotspotIfConditionsFulfilled(); + } + + private void startHotspotIfConditionsFulfilled() { + if (conditionManager.checkAndRequestConditions()) { + showInstallWarningIfNeeded(); + beginDelayedTransition((ViewGroup) requireView()); + startButton.setVisibility(INVISIBLE); + progressBar.setVisibility(VISIBLE); + progressTextView.setVisibility(VISIBLE); + viewModel.startHotspot(); + } + } + + private void onPermissionUpdate(boolean recheckPermissions) { + startButton.setEnabled(true); + if (recheckPermissions) { + startHotspotIfConditionsFulfilled(); + } + } + + private void showInstallWarningIfNeeded() { + Context ctx = requireContext(); + ApplicationInfo applicationInfo; + try { + applicationInfo = ctx.getPackageManager() + .getApplicationInfo(ctx.getPackageName(), 0); + } catch (PackageManager.NameNotFoundException e) { + throw new AssertionError(e); + } + // test only apps can not be installed + if ((applicationInfo.flags & FLAG_TEST_ONLY) == FLAG_TEST_ONLY) { + int color = getResources().getColor(R.color.briar_red_500); + Snackbar.make(requireView(), R.string.hotspot_flag_test, + LENGTH_LONG).setBackgroundTint(color).show(); + } + } + +} diff --git a/briar-android/src/main/java/org/briarproject/briar/android/hotspot/HotspotManager.java b/briar-android/src/main/java/org/briarproject/briar/android/hotspot/HotspotManager.java new file mode 100644 index 000000000..8d4300c31 --- /dev/null +++ b/briar-android/src/main/java/org/briarproject/briar/android/hotspot/HotspotManager.java @@ -0,0 +1,453 @@ +package org.briarproject.briar.android.hotspot; + +import android.annotation.SuppressLint; +import android.app.Application; +import android.content.Context; +import android.graphics.Bitmap; +import android.net.wifi.WifiManager; +import android.net.wifi.p2p.WifiP2pConfig; +import android.net.wifi.p2p.WifiP2pGroup; +import android.net.wifi.p2p.WifiP2pManager; +import android.net.wifi.p2p.WifiP2pManager.ActionListener; +import android.net.wifi.p2p.WifiP2pManager.GroupInfoListener; +import android.os.Handler; +import android.os.PowerManager; +import android.util.DisplayMetrics; + +import org.briarproject.bramble.api.db.DatabaseExecutor; +import org.briarproject.bramble.api.db.DbException; +import org.briarproject.bramble.api.lifecycle.IoExecutor; +import org.briarproject.bramble.api.nullsafety.MethodsNotNullByDefault; +import org.briarproject.bramble.api.nullsafety.ParametersNotNullByDefault; +import org.briarproject.bramble.api.settings.Settings; +import org.briarproject.bramble.api.settings.SettingsManager; +import org.briarproject.bramble.api.system.AndroidExecutor; +import org.briarproject.briar.R; +import org.briarproject.briar.android.hotspot.HotspotState.NetworkConfig; +import org.briarproject.briar.android.util.QrCodeUtils; + +import java.security.SecureRandom; +import java.util.concurrent.Executor; +import java.util.logging.Logger; + +import javax.inject.Inject; + +import androidx.annotation.Nullable; +import androidx.annotation.RequiresApi; +import androidx.annotation.UiThread; + +import static android.content.Context.POWER_SERVICE; +import static android.content.Context.WIFI_P2P_SERVICE; +import static android.content.Context.WIFI_SERVICE; +import static android.net.wifi.WifiManager.WIFI_MODE_FULL; +import static android.net.wifi.WifiManager.WIFI_MODE_FULL_HIGH_PERF; +import static android.net.wifi.p2p.WifiP2pConfig.GROUP_OWNER_BAND_2GHZ; +import static android.net.wifi.p2p.WifiP2pManager.BUSY; +import static android.net.wifi.p2p.WifiP2pManager.ERROR; +import static android.net.wifi.p2p.WifiP2pManager.NO_SERVICE_REQUESTS; +import static android.net.wifi.p2p.WifiP2pManager.P2P_UNSUPPORTED; +import static android.os.Build.VERSION.SDK_INT; +import static android.os.PowerManager.FULL_WAKE_LOCK; +import static java.util.Objects.requireNonNull; +import static java.util.logging.Level.INFO; +import static java.util.logging.Level.WARNING; +import static java.util.logging.Logger.getLogger; +import static org.briarproject.briar.android.util.UiUtils.handleException; + +@MethodsNotNullByDefault +@ParametersNotNullByDefault +class HotspotManager { + + interface HotspotListener { + @UiThread + void onStartingHotspot(); + + @IoExecutor + void onHotspotStarted(NetworkConfig networkConfig); + + @UiThread + void onDeviceConnected(); + + @UiThread + void onHotspotError(String error); + } + + private static final Logger LOG = getLogger(HotspotManager.class.getName()); + + private static final int MAX_FRAMEWORK_ATTEMPTS = 5; + private static final int MAX_GROUP_INFO_ATTEMPTS = 5; + private static final int RETRY_DELAY_MILLIS = 1000; + private static final String HOTSPOT_NAMESPACE = "hotspot"; + private static final String HOTSPOT_KEY_SSID = "ssid"; + private static final String HOTSPOT_KEY_PASS = "pass"; + + private final Context ctx; + @DatabaseExecutor + private final Executor dbExecutor; + @IoExecutor + private final Executor ioExecutor; + private final AndroidExecutor androidExecutor; + private final SettingsManager settingsManager; + private final SecureRandom random; + private final WifiManager wifiManager; + private final WifiP2pManager wifiP2pManager; + private final PowerManager powerManager; + private final Handler handler; + private final String lockTag; + + private HotspotListener listener; + private WifiManager.WifiLock wifiLock; + private PowerManager.WakeLock wakeLock; + private WifiP2pManager.Channel channel; + @Nullable + @RequiresApi(29) + private volatile NetworkConfig savedNetworkConfig = null; + + @Inject + HotspotManager(Application ctx, + @DatabaseExecutor Executor dbExecutor, + @IoExecutor Executor ioExecutor, + AndroidExecutor androidExecutor, + SettingsManager settingsManager, + SecureRandom random) { + this.ctx = ctx.getApplicationContext(); + this.dbExecutor = dbExecutor; + this.ioExecutor = ioExecutor; + this.androidExecutor = androidExecutor; + this.settingsManager = settingsManager; + this.random = random; + wifiManager = (WifiManager) ctx.getApplicationContext() + .getSystemService(WIFI_SERVICE); + wifiP2pManager = + (WifiP2pManager) ctx.getSystemService(WIFI_P2P_SERVICE); + powerManager = (PowerManager) ctx.getSystemService(POWER_SERVICE); + handler = new Handler(ctx.getMainLooper()); + lockTag = ctx.getPackageName() + ":app-sharing-hotspot"; + } + + void setHotspotListener(HotspotListener listener) { + this.listener = listener; + } + + @UiThread + void startWifiP2pHotspot() { + if (wifiP2pManager == null) { + listener.onHotspotError( + ctx.getString(R.string.hotspot_error_no_wifi_direct)); + return; + } + listener.onStartingHotspot(); + acquireLocks(); + startWifiP2pFramework(1); + } + + /** + * As soon as Wifi is enabled, we try starting the WifiP2p framework. + * If Wifi has just been enabled, it is possible that will fail. If that + * happens we try again for MAX_FRAMEWORK_ATTEMPTS times after a delay of + * RETRY_DELAY_MILLIS after each attempt. + *

+ * Rationale: it can take a few milliseconds for WifiP2p to become available + * after enabling Wifi. Depending on the API level it is possible to check this + * using {@link WifiP2pManager#requestP2pState} or register a BroadcastReceiver + * on the WIFI_P2P_STATE_CHANGED_ACTION to get notified when WifiP2p is really + * available. Trying to implement a solution that works reliably using these + * checks turned out to be a long rabbit-hole with lots of corner cases and + * workarounds for specific situations. + * Instead we now rely on this trial-and-error approach of just starting + * the framework and retrying if it fails. + *

+ * We'll realize that the framework is busy when the ActionListener passed + * to {@link WifiP2pManager#createGroup} is called with onFailure(BUSY) + */ + @UiThread + private void startWifiP2pFramework(int attempt) { + if (LOG.isLoggable(INFO)) { + LOG.info("startWifiP2pFramework attempt: " + attempt); + } + /* + * It is important that we call WifiP2pManager#initialize again + * for every attempt to starting the framework because otherwise, + * createGroup() will continue to fail with a BUSY state. + */ + channel = wifiP2pManager.initialize(ctx, ctx.getMainLooper(), null); + if (channel == null) { + releaseHotspotWithError( + ctx.getString(R.string.hotspot_error_no_wifi_direct)); + return; + } + + ActionListener listener = new ActionListener() { + @Override + // Callback for wifiP2pManager#createGroup() during startWifiP2pHotspot() + public void onSuccess() { + requestGroupInfo(1); + } + + @Override + // Callback for wifiP2pManager#createGroup() during startWifiP2pHotspot() + public void onFailure(int reason) { + if (LOG.isLoggable(INFO)) { + LOG.info("onFailure: " + reason); + } + if (reason == BUSY) { + // WifiP2p not ready yet or hotspot already running + restartWifiP2pFramework(attempt); + } else if (reason == P2P_UNSUPPORTED) { + releaseHotspotWithError(ctx.getString( + R.string.hotspot_error_start_callback_failed, + "p2p unsupported")); + } else if (reason == ERROR) { + releaseHotspotWithError(ctx.getString( + R.string.hotspot_error_start_callback_failed, + "p2p error")); + } else if (reason == NO_SERVICE_REQUESTS) { + releaseHotspotWithError(ctx.getString( + R.string.hotspot_error_start_callback_failed, + "no service requests")); + } else { + // all cases covered, in doubt set to error + releaseHotspotWithError(ctx.getString( + R.string.hotspot_error_start_callback_failed_unknown, + reason)); + } + } + }; + + try { + if (SDK_INT >= 29) { + Runnable createGroup = () -> { + NetworkConfig c = requireNonNull(savedNetworkConfig); + WifiP2pConfig config = new WifiP2pConfig.Builder() + .setGroupOperatingBand(GROUP_OWNER_BAND_2GHZ) + .setNetworkName(c.ssid) + .setPassphrase(c.password) + .build(); + wifiP2pManager.createGroup(channel, config, listener); + }; + if (savedNetworkConfig == null) { + // load savedNetworkConfig before starting hotspot + dbExecutor.execute(() -> { + loadSavedNetworkConfig(); + androidExecutor.runOnUiThread(createGroup); + }); + } else { + // savedNetworkConfig was already loaded, create group now + createGroup.run(); + } + } else { + wifiP2pManager.createGroup(channel, listener); + } + } catch (SecurityException e) { + // this should never happen, because we request permissions before + throw new AssertionError(e); + } + } + + @UiThread + private void restartWifiP2pFramework(int attempt) { + LOG.info("retrying to start WifiP2p framework"); + if (attempt < MAX_FRAMEWORK_ATTEMPTS) { + if (SDK_INT >= 27 && channel != null) channel.close(); + channel = null; + handler.postDelayed(() -> startWifiP2pFramework(attempt + 1), + RETRY_DELAY_MILLIS); + } else { + releaseHotspotWithError( + ctx.getString(R.string.hotspot_error_framework_busy)); + } + } + + @UiThread + void stopWifiP2pHotspot() { + if (channel == null) return; + wifiP2pManager.removeGroup(channel, new ActionListener() { + @Override + public void onSuccess() { + closeChannelAndReleaseLocks(); + } + + @Override + public void onFailure(int reason) { + // not propagating back error + if (LOG.isLoggable(WARNING)) { + LOG.warning("Error removing Wifi P2P group: " + reason); + } + closeChannelAndReleaseLocks(); + } + }); + } + + @SuppressLint("WakelockTimeout") + private void acquireLocks() { + // FLAG_KEEP_SCREEN_ON is not respected on some Huawei devices. + wakeLock = powerManager.newWakeLock(FULL_WAKE_LOCK, lockTag); + wakeLock.acquire(); + // WIFI_MODE_FULL has no effect on API >= 29 + int lockType = + SDK_INT >= 29 ? WIFI_MODE_FULL_HIGH_PERF : WIFI_MODE_FULL; + wifiLock = wifiManager.createWifiLock(lockType, lockTag); + wifiLock.acquire(); + } + + @UiThread + private void releaseHotspotWithError(String error) { + listener.onHotspotError(error); + closeChannelAndReleaseLocks(); + } + + @UiThread + private void closeChannelAndReleaseLocks() { + if (SDK_INT >= 27 && channel != null) channel.close(); + channel = null; + if (wakeLock.isHeld()) wakeLock.release(); + if (wifiLock.isHeld()) wifiLock.release(); + } + + @UiThread + private void requestGroupInfo(int attempt) { + if (LOG.isLoggable(INFO)) { + LOG.info("requestGroupInfo attempt: " + attempt); + } + GroupInfoListener groupListener = group -> { + boolean valid = isGroupValid(group); + // If the group is valid, set the hotspot to started. If we don't + // have any attempts left, we try what we got + if (valid || attempt >= MAX_GROUP_INFO_ATTEMPTS) { + onHotspotStarted(group); + } else { + retryRequestingGroupInfo(attempt); + } + }; + try { + if (channel == null) return; + wifiP2pManager.requestGroupInfo(channel, groupListener); + } catch (SecurityException e) { + // this should never happen, because we request permissions before + throw new AssertionError(e); + } + } + + @UiThread + private void onHotspotStarted(WifiP2pGroup group) { + DisplayMetrics dm = ctx.getResources().getDisplayMetrics(); + ioExecutor.execute(() -> { + String content = createWifiLoginString(group.getNetworkName(), + group.getPassphrase()); + Bitmap qrCode = QrCodeUtils.createQrCode(dm, content); + NetworkConfig config = new NetworkConfig(group.getNetworkName(), + group.getPassphrase(), qrCode); + listener.onHotspotStarted(config); + }); + requestGroupInfoForConnection(); + } + + private boolean isGroupValid(@Nullable WifiP2pGroup group) { + if (group == null) { + LOG.info("group is null"); + return false; + } else if (!group.getNetworkName().startsWith("DIRECT-")) { + LOG.info("received networkName without prefix 'DIRECT-'"); + return false; + } else if (SDK_INT >= 29) { + // if we get here, the savedNetworkConfig must have a value + String networkName = requireNonNull(savedNetworkConfig).ssid; + if (!networkName.equals(group.getNetworkName())) { + LOG.info("expected networkName does not match received one"); + return false; + } + } + return true; + } + + @UiThread + private void retryRequestingGroupInfo(int attempt) { + LOG.info("retrying to request group info"); + // On some devices we need to wait for the group info to become available + if (attempt < MAX_GROUP_INFO_ATTEMPTS) { + handler.postDelayed(() -> requestGroupInfo(attempt + 1), + RETRY_DELAY_MILLIS); + } else { + releaseHotspotWithError(ctx.getString( + R.string.hotspot_error_start_callback_no_group_info)); + } + } + + @UiThread + private void requestGroupInfoForConnection() { + LOG.info("requestGroupInfo for connection"); + GroupInfoListener groupListener = group -> { + if (group == null || group.getClientList().isEmpty()) { + handler.postDelayed(this::requestGroupInfoForConnection, + RETRY_DELAY_MILLIS); + } else { + listener.onDeviceConnected(); + } + }; + try { + if (channel == null) return; + wifiP2pManager.requestGroupInfo(channel, groupListener); + } catch (SecurityException e) { + throw new AssertionError(e); + } + } + + /** + * Store persistent Wi-Fi SSID and passphrase in Settings to improve UX + * so that users don't have to change them when attempting to connect. + * Works only on API 29 and above. + */ + @RequiresApi(29) + @DatabaseExecutor + private void loadSavedNetworkConfig() { + try { + Settings settings = settingsManager.getSettings(HOTSPOT_NAMESPACE); + String ssid = settings.get(HOTSPOT_KEY_SSID); + String pass = settings.get(HOTSPOT_KEY_PASS); + if (ssid == null || pass == null) { + ssid = getSsid(); + pass = getPassword(); + settings.put(HOTSPOT_KEY_SSID, ssid); + settings.put(HOTSPOT_KEY_PASS, pass); + settingsManager.mergeSettings(settings, HOTSPOT_NAMESPACE); + } + savedNetworkConfig = new NetworkConfig(ssid, pass, null); + } catch (DbException e) { + handleException(ctx, androidExecutor, LOG, e); + // probably never happens, but if lets use non-persistent data + String ssid = getSsid(); + String pass = getPassword(); + savedNetworkConfig = new NetworkConfig(ssid, pass, null); + } + } + + @RequiresApi(29) + private String getSsid() { + return "DIRECT-" + getRandomString(2) + "-" + + getRandomString(10); + } + + @RequiresApi(29) + private String getPassword() { + return getRandomString(8); + } + + private static String createWifiLoginString(String ssid, String password) { + // https://en.wikipedia.org/wiki/QR_code#WiFi_network_login + // do not remove the dangling ';', it can cause problems to omit it + return "WIFI:S:" + ssid + ";T:WPA;P:" + password + ";;"; + } + + // exclude chars that are easy to confuse: 0 O, 5 S, 1 l I + private static final String chars = + "2346789ABCDEFGHJKLMNPQRTUVWXYZabcdefghijkmnopqrstuvwxyz"; + + private String getRandomString(int length) { + char[] c = new char[length]; + for (int i = 0; i < length; i++) { + c[i] = chars.charAt(random.nextInt(chars.length())); + } + return new String(c); + } + +} diff --git a/briar-android/src/main/java/org/briarproject/briar/android/hotspot/HotspotModule.java b/briar-android/src/main/java/org/briarproject/briar/android/hotspot/HotspotModule.java new file mode 100644 index 000000000..ea7427b75 --- /dev/null +++ b/briar-android/src/main/java/org/briarproject/briar/android/hotspot/HotspotModule.java @@ -0,0 +1,18 @@ +package org.briarproject.briar.android.hotspot; + +import org.briarproject.briar.android.viewmodel.ViewModelKey; + +import androidx.lifecycle.ViewModel; +import dagger.Binds; +import dagger.Module; +import dagger.multibindings.IntoMap; + +@Module +public interface HotspotModule { + + @Binds + @IntoMap + @ViewModelKey(HotspotViewModel.class) + ViewModel bindHotspotViewModel(HotspotViewModel hotspotViewModel); + +} diff --git a/briar-android/src/main/java/org/briarproject/briar/android/hotspot/HotspotState.java b/briar-android/src/main/java/org/briarproject/briar/android/hotspot/HotspotState.java new file mode 100644 index 000000000..a152874eb --- /dev/null +++ b/briar-android/src/main/java/org/briarproject/briar/android/hotspot/HotspotState.java @@ -0,0 +1,88 @@ +package org.briarproject.briar.android.hotspot; + +import android.graphics.Bitmap; + +import org.briarproject.bramble.api.nullsafety.NotNullByDefault; + +import androidx.annotation.Nullable; +import androidx.annotation.UiThread; + +@NotNullByDefault +abstract class HotspotState { + + static class StartingHotspot extends HotspotState { + } + + static class NetworkConfig { + final String ssid, password; + @Nullable + final Bitmap qrCode; + + NetworkConfig(String ssid, String password, @Nullable Bitmap qrCode) { + this.ssid = ssid; + this.password = password; + this.qrCode = qrCode; + } + } + + static class WebsiteConfig { + final String url; + @Nullable + final Bitmap qrCode; + + WebsiteConfig(String url, @Nullable Bitmap qrCode) { + this.url = url; + this.qrCode = qrCode; + } + } + + static class HotspotStarted extends HotspotState { + private final NetworkConfig networkConfig; + private final WebsiteConfig websiteConfig; + // 'consumed' is set to true once this state triggered a UI change, i.e. + // moving to the next fragment. + private boolean consumed = false; + + HotspotStarted(NetworkConfig networkConfig, + WebsiteConfig websiteConfig) { + this.networkConfig = networkConfig; + this.websiteConfig = websiteConfig; + } + + NetworkConfig getNetworkConfig() { + return networkConfig; + } + + WebsiteConfig getWebsiteConfig() { + return websiteConfig; + } + + @UiThread + boolean wasNotYetConsumed() { + return !consumed; + } + + /** + * Mark this state as consumed, i.e. the UI has already done something + * as a result of the state changing to this. This can be used in order + * to not repeat actions such as showing fragments on rotation changes. + */ + @UiThread + void consume() { + consumed = true; + } + } + + static class HotspotError extends HotspotState { + private final String error; + + HotspotError(String error) { + this.error = error; + } + + String getError() { + return error; + } + } + +} diff --git a/briar-android/src/main/java/org/briarproject/briar/android/hotspot/HotspotViewModel.java b/briar-android/src/main/java/org/briarproject/briar/android/hotspot/HotspotViewModel.java new file mode 100644 index 000000000..ee9a984e7 --- /dev/null +++ b/briar-android/src/main/java/org/briarproject/briar/android/hotspot/HotspotViewModel.java @@ -0,0 +1,224 @@ +package org.briarproject.briar.android.hotspot; + +import android.app.Application; +import android.net.Uri; + +import org.briarproject.bramble.api.db.DatabaseExecutor; +import org.briarproject.bramble.api.db.TransactionManager; +import org.briarproject.bramble.api.lifecycle.IoExecutor; +import org.briarproject.bramble.api.lifecycle.LifecycleManager; +import org.briarproject.bramble.api.nullsafety.NotNullByDefault; +import org.briarproject.bramble.api.system.AndroidExecutor; +import org.briarproject.briar.R; +import org.briarproject.briar.android.hotspot.HotspotManager.HotspotListener; +import org.briarproject.briar.android.hotspot.HotspotState.HotspotError; +import org.briarproject.briar.android.hotspot.HotspotState.HotspotStarted; +import org.briarproject.briar.android.hotspot.HotspotState.NetworkConfig; +import org.briarproject.briar.android.hotspot.HotspotState.StartingHotspot; +import org.briarproject.briar.android.hotspot.HotspotState.WebsiteConfig; +import org.briarproject.briar.android.hotspot.WebServerManager.WebServerListener; +import org.briarproject.briar.android.viewmodel.DbViewModel; +import org.briarproject.briar.android.viewmodel.LiveEvent; +import org.briarproject.briar.android.viewmodel.MutableLiveEvent; +import org.briarproject.briar.api.android.AndroidNotificationManager; + +import java.io.File; +import java.io.FileInputStream; +import java.io.FileNotFoundException; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.OutputStream; +import java.util.concurrent.Executor; +import java.util.logging.Logger; + +import javax.inject.Inject; + +import androidx.annotation.Nullable; +import androidx.annotation.UiThread; +import androidx.lifecycle.LiveData; +import androidx.lifecycle.MutableLiveData; + +import static android.os.Build.VERSION.SDK_INT; +import static android.os.Environment.DIRECTORY_DOWNLOADS; +import static android.os.Environment.getExternalStoragePublicDirectory; +import static java.util.Objects.requireNonNull; +import static java.util.logging.Level.WARNING; +import static java.util.logging.Logger.getLogger; +import static org.briarproject.bramble.util.IoUtils.copyAndClose; +import static org.briarproject.briar.BuildConfig.DEBUG; +import static org.briarproject.briar.BuildConfig.VERSION_NAME; + +@NotNullByDefault +class HotspotViewModel extends DbViewModel + implements HotspotListener, WebServerListener { + + private static final Logger LOG = + getLogger(HotspotViewModel.class.getName()); + + @IoExecutor + private final Executor ioExecutor; + private final AndroidNotificationManager notificationManager; + private final HotspotManager hotspotManager; + private final WebServerManager webServerManager; + + private final MutableLiveData state = + new MutableLiveData<>(); + private final MutableLiveEvent peerConnected = + new MutableLiveEvent<>(); + private final MutableLiveEvent savedApkToUri = + new MutableLiveEvent<>(); + + @Nullable + // Field to temporarily store the network config received via onHotspotStarted() + // in order to post it along with a HotspotStarted status + private volatile NetworkConfig networkConfig; + + @Inject + HotspotViewModel(Application app, + @DatabaseExecutor Executor dbExecutor, + LifecycleManager lifecycleManager, + TransactionManager db, + AndroidExecutor androidExecutor, + @IoExecutor Executor ioExecutor, + HotspotManager hotspotManager, + WebServerManager webServerManager, + AndroidNotificationManager notificationManager) { + super(app, dbExecutor, lifecycleManager, db, androidExecutor); + this.ioExecutor = ioExecutor; + this.notificationManager = notificationManager; + this.hotspotManager = hotspotManager; + this.hotspotManager.setHotspotListener(this); + this.webServerManager = webServerManager; + this.webServerManager.setListener(this); + } + + @UiThread + void startHotspot() { + HotspotState s = state.getValue(); + if (s instanceof HotspotStarted) { + // This can happen if the user navigates back to intro fragment and + // taps 'start sharing' again. In this case, don't try to start the + // hotspot again. Instead, just create a new, unconsumed HotspotStarted + // event with the same config. + HotspotStarted old = (HotspotStarted) s; + state.setValue(new HotspotStarted(old.getNetworkConfig(), + old.getWebsiteConfig())); + } else { + hotspotManager.startWifiP2pHotspot(); + notificationManager.showHotspotNotification(); + } + } + + @UiThread + private void stopHotspot() { + ioExecutor.execute(webServerManager::stopWebServer); + hotspotManager.stopWifiP2pHotspot(); + notificationManager.clearHotspotNotification(); + } + + @Override + protected void onCleared() { + super.onCleared(); + stopHotspot(); + } + + @Override + public void onStartingHotspot() { + state.setValue(new StartingHotspot()); + } + + @Override + @IoExecutor + public void onHotspotStarted(NetworkConfig networkConfig) { + this.networkConfig = networkConfig; + LOG.info("starting webserver"); + webServerManager.startWebServer(); + } + + @UiThread + @Override + public void onDeviceConnected() { + peerConnected.setEvent(true); + } + + @Override + public void onHotspotError(String error) { + if (LOG.isLoggable(WARNING)) { + LOG.warning("Hotspot error: " + error); + } + state.postValue(new HotspotError(error)); + ioExecutor.execute(webServerManager::stopWebServer); + notificationManager.clearHotspotNotification(); + } + + @Override + @IoExecutor + public void onWebServerStarted(WebsiteConfig websiteConfig) { + NetworkConfig nc = requireNonNull(networkConfig); + state.postValue(new HotspotStarted(nc, websiteConfig)); + networkConfig = null; + } + + @Override + @IoExecutor + public void onWebServerError() { + state.postValue(new HotspotError(getApplication() + .getString(R.string.hotspot_error_web_server_start))); + stopHotspot(); + } + + void exportApk(Uri uri) { + if (SDK_INT < 19) throw new IllegalStateException(); + try { + OutputStream out = getApplication().getContentResolver() + .openOutputStream(uri, "wt"); + writeApk(out, uri); + } catch (FileNotFoundException e) { + handleException(e); + } + } + + void exportApk() { + if (SDK_INT >= 19) throw new IllegalStateException(); + File path = getExternalStoragePublicDirectory(DIRECTORY_DOWNLOADS); + //noinspection ResultOfMethodCallIgnored + path.mkdirs(); + File file = new File(path, getApkFileName()); + try { + OutputStream out = new FileOutputStream(file); + writeApk(out, Uri.fromFile(file)); + } catch (FileNotFoundException e) { + handleException(e); + } + } + + static String getApkFileName() { + return "briar" + (DEBUG ? "-debug-" : "-") + VERSION_NAME + ".apk"; + } + + private void writeApk(OutputStream out, Uri uriToShare) { + File apk = new File(getApplication().getPackageCodePath()); + ioExecutor.execute(() -> { + try { + FileInputStream in = new FileInputStream(apk); + copyAndClose(in, out); + savedApkToUri.postEvent(uriToShare); + } catch (IOException e) { + handleException(e); + } + }); + } + + LiveData getState() { + return state; + } + + LiveEvent getPeerConnectedEvent() { + return peerConnected; + } + + LiveEvent getSavedApkToUri() { + return savedApkToUri; + } + +} diff --git a/briar-android/src/main/java/org/briarproject/briar/android/hotspot/ManualHotspotFragment.java b/briar-android/src/main/java/org/briarproject/briar/android/hotspot/ManualHotspotFragment.java new file mode 100644 index 000000000..4f2fa72f7 --- /dev/null +++ b/briar-android/src/main/java/org/briarproject/briar/android/hotspot/ManualHotspotFragment.java @@ -0,0 +1,119 @@ +package org.briarproject.briar.android.hotspot; + +import android.content.Context; +import android.os.Bundle; +import android.text.SpannableString; +import android.text.method.LinkMovementMethod; +import android.text.style.ClickableSpan; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.TextView; + +import org.briarproject.bramble.api.nullsafety.MethodsNotNullByDefault; +import org.briarproject.bramble.api.nullsafety.ParametersNotNullByDefault; +import org.briarproject.briar.R; + +import javax.inject.Inject; + +import androidx.annotation.Nullable; +import androidx.core.util.Consumer; +import androidx.fragment.app.Fragment; +import androidx.lifecycle.ViewModelProvider; +import androidx.viewpager2.widget.ViewPager2; + +import static android.text.Spanned.SPAN_EXCLUSIVE_EXCLUSIVE; +import static android.view.View.GONE; +import static org.briarproject.briar.android.AppModule.getAndroidComponent; +import static org.briarproject.briar.android.hotspot.AbstractTabsFragment.ARG_FOR_WIFI_CONNECT; +import static org.briarproject.briar.android.hotspot.HotspotState.HotspotStarted; + +@MethodsNotNullByDefault +@ParametersNotNullByDefault +public class ManualHotspotFragment extends Fragment { + + public final static String TAG = ManualHotspotFragment.class.getName(); + + @Inject + ViewModelProvider.Factory viewModelFactory; + + private HotspotViewModel viewModel; + + static ManualHotspotFragment newInstance(boolean forWifiConnect) { + ManualHotspotFragment f = new ManualHotspotFragment(); + Bundle bundle = new Bundle(); + bundle.putBoolean(ARG_FOR_WIFI_CONNECT, forWifiConnect); + f.setArguments(bundle); + return f; + } + + @Override + public void onAttach(Context context) { + super.onAttach(context); + getAndroidComponent(requireContext()).inject(this); + viewModel = new ViewModelProvider(requireActivity(), viewModelFactory) + .get(HotspotViewModel.class); + } + + @Override + public View onCreateView(LayoutInflater inflater, + @Nullable ViewGroup container, + @Nullable Bundle savedInstanceState) { + return inflater + .inflate(R.layout.fragment_hotspot_manual, container, false); + } + + @Override + public void onViewCreated(View v, @Nullable Bundle savedInstanceState) { + super.onViewCreated(v, savedInstanceState); + + TextView manualIntroView = v.findViewById(R.id.manualIntroView); + TextView ssidLabelView = v.findViewById(R.id.ssidLabelView); + TextView ssidView = v.findViewById(R.id.ssidView); + TextView passwordView = v.findViewById(R.id.passwordView); + + Consumer consumer; + if (requireArguments().getBoolean(ARG_FOR_WIFI_CONNECT)) { + linkify(manualIntroView, R.string.hotspot_manual_wifi); + ssidLabelView.setText(R.string.hotspot_manual_wifi_ssid); + consumer = state -> { + ssidView.setText(state.getNetworkConfig().ssid); + passwordView.setText(state.getNetworkConfig().password); + }; + } else { + linkify(manualIntroView, R.string.hotspot_manual_site); + ssidLabelView.setText(R.string.hotspot_manual_site_address); + consumer = state -> ssidView.setText(state.getWebsiteConfig().url); + v.findViewById(R.id.passwordLabelView).setVisibility(GONE); + passwordView.setVisibility(GONE); + } + viewModel.getState().observe(getViewLifecycleOwner(), state -> { + // we only expect to be in this state here + if (state instanceof HotspotStarted) { + consumer.accept((HotspotStarted) state); + } + }); + } + + private void linkify(TextView textView, int resPattern) { + String pattern = getString(resPattern); + String replacement = getString(R.string.hotspot_scanning_a_qr_code); + String text = String.format(pattern, replacement); + int start = pattern.indexOf("%s"); + int end = start + replacement.length(); + SpannableString spannable = new SpannableString(text); + ClickableSpan clickable = new ClickableSpan() { + + @Override + public void onClick(View textView) { + ViewPager2 pager = requireActivity().findViewById(R.id.pager); + pager.setCurrentItem(1); + } + + }; + spannable.setSpan(clickable, start, end, SPAN_EXCLUSIVE_EXCLUSIVE); + + textView.setText(spannable); + textView.setMovementMethod(LinkMovementMethod.getInstance()); + } +} diff --git a/briar-android/src/main/java/org/briarproject/briar/android/hotspot/QrHotspotFragment.java b/briar-android/src/main/java/org/briarproject/briar/android/hotspot/QrHotspotFragment.java new file mode 100644 index 000000000..a613804f5 --- /dev/null +++ b/briar-android/src/main/java/org/briarproject/briar/android/hotspot/QrHotspotFragment.java @@ -0,0 +1,86 @@ +package org.briarproject.briar.android.hotspot; + +import android.content.Context; +import android.graphics.Bitmap; +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.ImageView; +import android.widget.TextView; +import android.widget.Toast; + +import org.briarproject.bramble.api.nullsafety.MethodsNotNullByDefault; +import org.briarproject.bramble.api.nullsafety.ParametersNotNullByDefault; +import org.briarproject.briar.R; +import org.briarproject.briar.android.hotspot.HotspotState.HotspotStarted; + +import javax.inject.Inject; + +import androidx.annotation.Nullable; +import androidx.fragment.app.Fragment; +import androidx.lifecycle.ViewModelProvider; + +import static org.briarproject.briar.android.AppModule.getAndroidComponent; +import static org.briarproject.briar.android.hotspot.AbstractTabsFragment.ARG_FOR_WIFI_CONNECT; + +@MethodsNotNullByDefault +@ParametersNotNullByDefault +public class QrHotspotFragment extends Fragment { + + public final static String TAG = QrHotspotFragment.class.getName(); + + @Inject + ViewModelProvider.Factory viewModelFactory; + + private HotspotViewModel viewModel; + + static QrHotspotFragment newInstance(boolean forWifiConnect) { + QrHotspotFragment f = new QrHotspotFragment(); + Bundle bundle = new Bundle(); + bundle.putBoolean(ARG_FOR_WIFI_CONNECT, forWifiConnect); + f.setArguments(bundle); + return f; + } + + @Override + public void onAttach(Context context) { + super.onAttach(context); + getAndroidComponent(requireContext()).inject(this); + viewModel = new ViewModelProvider(requireActivity(), viewModelFactory) + .get(HotspotViewModel.class); + } + + @Override + public View onCreateView(LayoutInflater inflater, + @Nullable ViewGroup container, + @Nullable Bundle savedInstanceState) { + View v = inflater + .inflate(R.layout.fragment_hotspot_qr, container, false); + + TextView qrIntroView = v.findViewById(R.id.qrIntroView); + ImageView qrCodeView = v.findViewById(R.id.qrCodeView); + + boolean forWifi = requireArguments().getBoolean(ARG_FOR_WIFI_CONNECT); + + qrIntroView.setText(forWifi ? R.string.hotspot_qr_wifi : + R.string.hotspot_qr_site); + + viewModel.getState().observe(getViewLifecycleOwner(), state -> { + if (state instanceof HotspotStarted) { + HotspotStarted s = (HotspotStarted) state; + Bitmap qrCode = forWifi ? s.getNetworkConfig().qrCode : + s.getWebsiteConfig().qrCode; + if (qrCode == null) { + Toast.makeText(requireContext(), R.string.error, + Toast.LENGTH_SHORT).show(); + qrCodeView.setImageResource(R.drawable.ic_image_broken); + } else { + qrCodeView.setImageBitmap(qrCode); + } + } + }); + return v; + } + +} diff --git a/briar-android/src/main/java/org/briarproject/briar/android/hotspot/WebServer.java b/briar-android/src/main/java/org/briarproject/briar/android/hotspot/WebServer.java new file mode 100644 index 000000000..b9e9b6133 --- /dev/null +++ b/briar-android/src/main/java/org/briarproject/briar/android/hotspot/WebServer.java @@ -0,0 +1,135 @@ +package org.briarproject.briar.android.hotspot; + +import android.content.Context; + +import org.briarproject.bramble.api.nullsafety.NotNullByDefault; +import org.briarproject.briar.R; +import org.jsoup.Jsoup; +import org.jsoup.nodes.Document; + +import java.io.File; +import java.io.FileInputStream; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.InputStream; +import java.util.logging.Logger; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import androidx.annotation.Nullable; +import fi.iki.elonen.NanoHTTPD; + +import static android.util.Xml.Encoding.UTF_8; +import static fi.iki.elonen.NanoHTTPD.Response.Status.INTERNAL_ERROR; +import static fi.iki.elonen.NanoHTTPD.Response.Status.NOT_FOUND; +import static fi.iki.elonen.NanoHTTPD.Response.Status.OK; +import static java.util.Objects.requireNonNull; +import static java.util.logging.Level.WARNING; +import static java.util.logging.Logger.getLogger; +import static org.briarproject.bramble.util.LogUtils.logException; +import static org.briarproject.briar.BuildConfig.VERSION_NAME; +import static org.briarproject.briar.android.hotspot.HotspotViewModel.getApkFileName; + +@NotNullByDefault +class WebServer extends NanoHTTPD { + + final static int PORT = 9999; + + private static final Logger LOG = getLogger(WebServer.class.getName()); + private static final String FILE_HTML = "hotspot.html"; + private static final Pattern REGEX_AGENT = + Pattern.compile("Android ([0-9]+)"); + + private final Context ctx; + + WebServer(Context ctx) { + super(PORT); + this.ctx = ctx; + } + + @Override + public void start() throws IOException { + start(NanoHTTPD.SOCKET_READ_TIMEOUT, false); + } + + @Override + public Response serve(IHTTPSession session) { + if (session.getUri().endsWith("favicon.ico")) { + return newFixedLengthResponse(NOT_FOUND, MIME_PLAINTEXT, + NOT_FOUND.getDescription()); + } + if (session.getUri().endsWith(".apk")) { + return serveApk(); + } + Response res; + try { + String html = getHtml(session.getHeaders().get("user-agent")); + res = newFixedLengthResponse(OK, MIME_HTML, html); + } catch (Exception e) { + logException(LOG, WARNING, e); + res = newFixedLengthResponse(INTERNAL_ERROR, MIME_PLAINTEXT, + ctx.getString(R.string.hotspot_error_web_server_serve)); + } + return res; + } + + private String getHtml(@Nullable String userAgent) throws Exception { + Document doc; + try (InputStream is = ctx.getAssets().open(FILE_HTML)) { + doc = Jsoup.parse(is, UTF_8.name(), ""); + } + String app = ctx.getString(R.string.app_name); + String appV = app + " " + VERSION_NAME; + String filename = getApkFileName(); + doc.select("#download_title").first() + .text(ctx.getString(R.string.website_download_title, appV)); + doc.select("#download_intro").first() + .text(ctx.getString(R.string.website_download_intro, app)); + doc.select(".button").first().attr("href", filename); + doc.select("#download_button").first() + .text(ctx.getString(R.string.website_download_title, app)); + doc.select("#download_outro").first() + .text(ctx.getString(R.string.website_download_outro)); + doc.select("#troubleshooting_title").first() + .text(ctx.getString(R.string.website_troubleshooting_title)); + doc.select("#troubleshooting_1").first() + .text(ctx.getString(R.string.website_troubleshooting_1)); + doc.select("#troubleshooting_2").first() + .text(getUnknownSourcesString(userAgent)); + return doc.outerHtml(); + } + + private String getUnknownSourcesString(@Nullable String userAgent) { + boolean is8OrHigher = false; + if (userAgent != null) { + Matcher matcher = REGEX_AGENT.matcher(userAgent); + if (matcher.find()) { + int androidMajorVersion = + Integer.parseInt(requireNonNull(matcher.group(1))); + is8OrHigher = androidMajorVersion >= 8; + } + } + return is8OrHigher ? + ctx.getString(R.string.website_troubleshooting_2_new) : + ctx.getString(R.string.website_troubleshooting_2_old); + } + + private Response serveApk() { + String mime = "application/vnd.android.package-archive"; + + File file = new File(ctx.getPackageCodePath()); + long fileLen = file.length(); + + Response res; + try { + FileInputStream fis = new FileInputStream(file); + res = newFixedLengthResponse(OK, mime, fis, fileLen); + res.addHeader("Content-Length", "" + fileLen); + } catch (FileNotFoundException e) { + logException(LOG, WARNING, e); + res = newFixedLengthResponse(NOT_FOUND, MIME_PLAINTEXT, + ctx.getString(R.string.hotspot_error_web_server_serve)); + } + return res; + } +} diff --git a/briar-android/src/main/java/org/briarproject/briar/android/hotspot/WebServerManager.java b/briar-android/src/main/java/org/briarproject/briar/android/hotspot/WebServerManager.java new file mode 100644 index 000000000..c5a720b37 --- /dev/null +++ b/briar-android/src/main/java/org/briarproject/briar/android/hotspot/WebServerManager.java @@ -0,0 +1,127 @@ +package org.briarproject.briar.android.hotspot; + +import android.app.Application; +import android.graphics.Bitmap; +import android.util.DisplayMetrics; + +import org.briarproject.bramble.api.lifecycle.IoExecutor; +import org.briarproject.bramble.api.nullsafety.MethodsNotNullByDefault; +import org.briarproject.bramble.api.nullsafety.ParametersNotNullByDefault; +import org.briarproject.briar.android.hotspot.HotspotState.WebsiteConfig; +import org.briarproject.briar.android.util.QrCodeUtils; + +import java.io.IOException; +import java.net.InetAddress; +import java.net.InterfaceAddress; +import java.net.NetworkInterface; +import java.net.SocketException; +import java.util.Enumeration; +import java.util.List; +import java.util.logging.Logger; + +import javax.inject.Inject; + +import androidx.annotation.Nullable; +import androidx.annotation.UiThread; + +import static java.util.Collections.emptyList; +import static java.util.Collections.list; +import static java.util.logging.Level.INFO; +import static java.util.logging.Level.WARNING; +import static java.util.logging.Logger.getLogger; +import static org.briarproject.bramble.util.LogUtils.logException; +import static org.briarproject.briar.android.hotspot.WebServer.PORT; + +@MethodsNotNullByDefault +@ParametersNotNullByDefault +class WebServerManager { + + interface WebServerListener { + @IoExecutor + void onWebServerStarted(WebsiteConfig websiteConfig); + + @IoExecutor + void onWebServerError(); + } + + private static final Logger LOG = + getLogger(WebServerManager.class.getName()); + + private final WebServer webServer; + private final DisplayMetrics dm; + + private volatile WebServerListener listener; + + @Inject + WebServerManager(Application ctx) { + webServer = new WebServer(ctx); + dm = ctx.getResources().getDisplayMetrics(); + } + + @UiThread + void setListener(WebServerListener listener) { + this.listener = listener; + } + + @IoExecutor + void startWebServer() { + try { + webServer.start(); + onWebServerStarted(); + } catch (IOException e) { + logException(LOG, WARNING, e); + listener.onWebServerError(); + } + } + + @IoExecutor + private void onWebServerStarted() { + String url = "http://192.168.49.1:" + PORT; + InetAddress address = getAccessPointAddress(); + if (address == null) { + LOG.info( + "Could not find access point address, assuming 192.168.49.1"); + } else { + if (LOG.isLoggable(INFO)) { + LOG.info("Access point address " + address.getHostAddress()); + } + url = "http://" + address.getHostAddress() + ":" + PORT; + } + Bitmap qrCode = QrCodeUtils.createQrCode(dm, url); + listener.onWebServerStarted(new WebsiteConfig(url, qrCode)); + } + + /** + * It is safe to call this more than once and it won't throw. + */ + @IoExecutor + void stopWebServer() { + webServer.stop(); + } + + @Nullable + private static InetAddress getAccessPointAddress() { + for (NetworkInterface i : getNetworkInterfaces()) { + if (i.getName().startsWith("p2p")) { + for (InterfaceAddress a : i.getInterfaceAddresses()) { + // we consider only IPv4 addresses + if (a.getAddress().getAddress().length == 4) + return a.getAddress(); + } + } + } + return null; + } + + private static List getNetworkInterfaces() { + try { + Enumeration ifaces = + NetworkInterface.getNetworkInterfaces(); + return ifaces == null ? emptyList() : list(ifaces); + } catch (SocketException e) { + logException(LOG, WARNING, e); + return emptyList(); + } + } + +} diff --git a/briar-android/src/main/java/org/briarproject/briar/android/hotspot/WebsiteFragment.java b/briar-android/src/main/java/org/briarproject/briar/android/hotspot/WebsiteFragment.java new file mode 100644 index 000000000..6ccf4d9a7 --- /dev/null +++ b/briar-android/src/main/java/org/briarproject/briar/android/hotspot/WebsiteFragment.java @@ -0,0 +1,36 @@ +package org.briarproject.briar.android.hotspot; + +import android.os.Bundle; +import android.view.View; + +import org.briarproject.bramble.api.nullsafety.MethodsNotNullByDefault; +import org.briarproject.bramble.api.nullsafety.ParametersNotNullByDefault; + +import androidx.annotation.Nullable; +import androidx.fragment.app.Fragment; + +import static android.view.View.GONE; + +@MethodsNotNullByDefault +@ParametersNotNullByDefault +public class WebsiteFragment extends AbstractTabsFragment { + + public final static String TAG = WebsiteFragment.class.getName(); + + @Override + public void onViewCreated(View view, @Nullable Bundle savedInstanceState) { + super.onViewCreated(view, savedInstanceState); + connectedButton.setVisibility(GONE); + } + + @Override + protected Fragment getFirstFragment() { + return ManualHotspotFragment.newInstance(false); + } + + @Override + protected Fragment getSecondFragment() { + return QrHotspotFragment.newInstance(false); + } + +} diff --git a/briar-android/src/main/java/org/briarproject/briar/android/reporting/BriarExceptionHandler.java b/briar-android/src/main/java/org/briarproject/briar/android/reporting/BriarExceptionHandler.java index 0f99caf3c..870285954 100644 --- a/briar-android/src/main/java/org/briarproject/briar/android/reporting/BriarExceptionHandler.java +++ b/briar-android/src/main/java/org/briarproject/briar/android/reporting/BriarExceptionHandler.java @@ -38,7 +38,7 @@ class BriarExceptionHandler implements UncaughtExceptionHandler { // activity runs in its own process, so we can kill the old one startDevReportActivity(app.getApplicationContext(), - CrashReportActivity.class, e, appStartTime, logKey); + CrashReportActivity.class, e, appStartTime, logKey, null); Process.killProcess(Process.myPid()); System.exit(10); } diff --git a/briar-android/src/main/java/org/briarproject/briar/android/reporting/CrashReportActivity.java b/briar-android/src/main/java/org/briarproject/briar/android/reporting/CrashReportActivity.java index e07b63d53..38dd7c545 100644 --- a/briar-android/src/main/java/org/briarproject/briar/android/reporting/CrashReportActivity.java +++ b/briar-android/src/main/java/org/briarproject/briar/android/reporting/CrashReportActivity.java @@ -33,6 +33,7 @@ import static java.util.Objects.requireNonNull; public class CrashReportActivity extends BaseActivity implements BaseFragmentListener { + public static final String EXTRA_INITIAL_COMMENT = "initialComment"; public static final String EXTRA_THROWABLE = "throwable"; public static final String EXTRA_APP_START_TIME = "appStartTime"; public static final String EXTRA_APP_LOGCAT = "logcat"; @@ -55,10 +56,11 @@ public class CrashReportActivity extends BaseActivity setContentView(R.layout.activity_dev_report); Intent intent = getIntent(); + String initialComment = intent.getStringExtra(EXTRA_INITIAL_COMMENT); Throwable t = (Throwable) intent.getSerializableExtra(EXTRA_THROWABLE); long appStartTime = intent.getLongExtra(EXTRA_APP_START_TIME, -1); byte[] logKey = intent.getByteArrayExtra(EXTRA_APP_LOGCAT); - viewModel.init(t, appStartTime, logKey); + viewModel.init(t, appStartTime, logKey, initialComment); viewModel.getShowReport().observeEvent(this, show -> { if (show) displayFragment(true); }); diff --git a/briar-android/src/main/java/org/briarproject/briar/android/reporting/ReportFormFragment.java b/briar-android/src/main/java/org/briarproject/briar/android/reporting/ReportFormFragment.java index 2efd021e8..328ced375 100644 --- a/briar-android/src/main/java/org/briarproject/briar/android/reporting/ReportFormFragment.java +++ b/briar-android/src/main/java/org/briarproject/briar/android/reporting/ReportFormFragment.java @@ -78,6 +78,9 @@ public class ReportFormFragment extends BaseFragment { list = v.findViewById(R.id.list); progress = v.findViewById(R.id.progress_wheel); + if (viewModel.getInitialComment() != null) + userCommentView.setText(viewModel.getInitialComment()); + if (viewModel.isFeedback()) { includeDebugReport .setText(getString(R.string.include_debug_report_feedback)); diff --git a/briar-android/src/main/java/org/briarproject/briar/android/reporting/ReportViewModel.java b/briar-android/src/main/java/org/briarproject/briar/android/reporting/ReportViewModel.java index ef8679b7b..6e0a898b6 100644 --- a/briar-android/src/main/java/org/briarproject/briar/android/reporting/ReportViewModel.java +++ b/briar-android/src/main/java/org/briarproject/briar/android/reporting/ReportViewModel.java @@ -64,6 +64,8 @@ class ReportViewModel extends AndroidViewModel { private final MutableLiveEvent closeReport = new MutableLiveEvent<>(); private boolean isFeedback; + @Nullable + private String initialComment; @Inject ReportViewModel(@NonNull Application application, @@ -80,7 +82,8 @@ class ReportViewModel extends AndroidViewModel { } void init(@Nullable Throwable t, long appStartTime, - @Nullable byte[] logKey) { + @Nullable byte[] logKey, @Nullable String initialComment) { + this.initialComment = initialComment; isFeedback = t == null; if (reportData.getValue() == null) new SingleShotAndroidExecutor(() -> { String decryptedLogs; @@ -103,6 +106,11 @@ class ReportViewModel extends AndroidViewModel { }).start(); } + @Nullable + String getInitialComment() { + return initialComment; + } + boolean isFeedback() { return isFeedback; } @@ -140,7 +148,7 @@ class ReportViewModel extends AndroidViewModel { /** * The content of the report that will be loaded after - * {@link #init(Throwable, long, byte[])} was called. + * {@link #init(Throwable, long, byte[], String)} was called. */ LiveData getReportData() { return reportData; diff --git a/briar-android/src/main/java/org/briarproject/briar/android/settings/SettingsFragment.java b/briar-android/src/main/java/org/briarproject/briar/android/settings/SettingsFragment.java index df8543c1a..5f4fd2f16 100644 --- a/briar-android/src/main/java/org/briarproject/briar/android/settings/SettingsFragment.java +++ b/briar-android/src/main/java/org/briarproject/briar/android/settings/SettingsFragment.java @@ -36,6 +36,7 @@ public class SettingsFragment extends PreferenceFragmentCompat { private static final String PREF_KEY_FEEDBACK = "pref_key_send_feedback"; private static final String PREF_KEY_DEV = "pref_key_dev"; private static final String PREF_KEY_EXPLODE = "pref_key_explode"; + private static final String PREF_KEY_SHARE_APP = "pref_key_share_app"; @Inject ViewModelProvider.Factory viewModelFactory; @@ -85,6 +86,12 @@ public class SettingsFragment extends PreferenceFragmentCompat { PreferenceGroup dev = requireNonNull(findPreference(PREF_KEY_DEV)); dev.setVisible(false); } + + if (!viewModel.shouldEnableShareAppViaOfflineHotspot()) { + Preference shareApp = + requireNonNull(findPreference(PREF_KEY_SHARE_APP)); + shareApp.setVisible(false); + } } @Override diff --git a/briar-android/src/main/java/org/briarproject/briar/android/settings/SettingsViewModel.java b/briar-android/src/main/java/org/briarproject/briar/android/settings/SettingsViewModel.java index ba92042f0..f6aa3da2f 100644 --- a/briar-android/src/main/java/org/briarproject/briar/android/settings/SettingsViewModel.java +++ b/briar-android/src/main/java/org/briarproject/briar/android/settings/SettingsViewModel.java @@ -262,4 +262,8 @@ class SettingsViewModel extends DbViewModel implements EventListener { return screenLockTimeout; } + boolean shouldEnableShareAppViaOfflineHotspot() { + return featureFlags.shouldEnableShareAppViaOfflineHotspot(); + } + } diff --git a/briar-android/src/main/java/org/briarproject/briar/android/contact/add/nearby/QrCodeUtils.java b/briar-android/src/main/java/org/briarproject/briar/android/util/QrCodeUtils.java similarity index 91% rename from briar-android/src/main/java/org/briarproject/briar/android/contact/add/nearby/QrCodeUtils.java rename to briar-android/src/main/java/org/briarproject/briar/android/util/QrCodeUtils.java index 26f2bf050..ae8fa55e6 100644 --- a/briar-android/src/main/java/org/briarproject/briar/android/contact/add/nearby/QrCodeUtils.java +++ b/briar-android/src/main/java/org/briarproject/briar/android/util/QrCodeUtils.java @@ -1,4 +1,4 @@ -package org.briarproject.briar.android.contact.add.nearby; +package org.briarproject.briar.android.util; import android.graphics.Bitmap; import android.util.DisplayMetrics; @@ -22,12 +22,12 @@ import static java.util.logging.Logger.getLogger; import static org.briarproject.bramble.util.LogUtils.logException; @NotNullByDefault -class QrCodeUtils { +public class QrCodeUtils { private static final Logger LOG = getLogger(QrCodeUtils.class.getName()); @Nullable - static Bitmap createQrCode(DisplayMetrics dm, String input) { + public static Bitmap createQrCode(DisplayMetrics dm, String input) { int smallestDimen = Math.min(dm.widthPixels, dm.heightPixels); try { // Generate QR code diff --git a/briar-android/src/main/java/org/briarproject/briar/android/util/UiUtils.java b/briar-android/src/main/java/org/briarproject/briar/android/util/UiUtils.java index e62e75397..ae1e43131 100644 --- a/briar-android/src/main/java/org/briarproject/briar/android/util/UiUtils.java +++ b/briar-android/src/main/java/org/briarproject/briar/android/util/UiUtils.java @@ -61,6 +61,7 @@ import androidx.core.util.Consumer; import androidx.fragment.app.Fragment; import androidx.fragment.app.FragmentActivity; import androidx.fragment.app.FragmentManager; +import androidx.fragment.app.FragmentTransaction; import androidx.lifecycle.LifecycleOwner; import androidx.lifecycle.LiveData; import androidx.lifecycle.Observer; @@ -111,6 +112,7 @@ import static org.briarproject.briar.BuildConfig.APPLICATION_ID; import static org.briarproject.briar.android.TestingConstants.EXPIRY_DATE; import static org.briarproject.briar.android.reporting.CrashReportActivity.EXTRA_APP_LOGCAT; import static org.briarproject.briar.android.reporting.CrashReportActivity.EXTRA_APP_START_TIME; +import static org.briarproject.briar.android.reporting.CrashReportActivity.EXTRA_INITIAL_COMMENT; import static org.briarproject.briar.android.reporting.CrashReportActivity.EXTRA_THROWABLE; @MethodsNotNullByDefault @@ -137,13 +139,18 @@ public class UiUtils { public static void showFragment(FragmentManager fm, Fragment f, @Nullable String tag) { - fm.beginTransaction() + showFragment(fm, f, tag, true); + } + + public static void showFragment(FragmentManager fm, Fragment f, + @Nullable String tag, boolean addToBackStack) { + FragmentTransaction ta = fm.beginTransaction() .setCustomAnimations(R.anim.step_next_in, R.anim.step_previous_out, R.anim.step_previous_in, R.anim.step_next_out) - .replace(R.id.fragmentContainer, f, tag) - .addToBackStack(tag) - .commit(); + .replace(R.id.fragmentContainer, f, tag); + if (addToBackStack) ta.addToBackStack(tag); + ta.commit(); } public static String getContactDisplayName(Author author, @@ -410,17 +417,25 @@ public class UiUtils { } public static void triggerFeedback(Context ctx) { - startDevReportActivity(ctx, FeedbackActivity.class, null, null, null); + triggerFeedback(ctx, null); + } + + public static void triggerFeedback(Context ctx, + @Nullable String initialComment) { + startDevReportActivity(ctx, FeedbackActivity.class, null, null, null, + initialComment); } public static void startDevReportActivity(Context ctx, Class activity, @Nullable Throwable t, - @Nullable Long appStartTime, @Nullable byte[] logKey) { + @Nullable Long appStartTime, @Nullable byte[] logKey, @Nullable + String initialComment) { final Intent dialogIntent = new Intent(ctx, activity); dialogIntent.setFlags(FLAG_ACTIVITY_NEW_TASK); dialogIntent.putExtra(EXTRA_THROWABLE, t); dialogIntent.putExtra(EXTRA_APP_START_TIME, appStartTime); dialogIntent.putExtra(EXTRA_APP_LOGCAT, logKey); + dialogIntent.putExtra(EXTRA_INITIAL_COMMENT, initialComment); ctx.startActivity(dialogIntent); } @@ -546,4 +561,5 @@ public class UiUtils { activity.getWindow().setSoftInputMode(SOFT_INPUT_ADJUST_RESIZE | SOFT_INPUT_STATE_HIDDEN); } + } diff --git a/briar-android/src/main/java/org/briarproject/briar/api/android/AndroidNotificationManager.java b/briar-android/src/main/java/org/briarproject/briar/api/android/AndroidNotificationManager.java index 765425720..908b27322 100644 --- a/briar-android/src/main/java/org/briarproject/briar/api/android/AndroidNotificationManager.java +++ b/briar-android/src/main/java/org/briarproject/briar/api/android/AndroidNotificationManager.java @@ -31,6 +31,7 @@ public interface AndroidNotificationManager { int FORUM_POST_NOTIFICATION_ID = 6; int BLOG_POST_NOTIFICATION_ID = 7; int CONTACT_ADDED_NOTIFICATION_ID = 8; + int HOTSPOT_NOTIFICATION_ID = 9; // Channel IDs String CONTACT_CHANNEL_ID = "contacts"; @@ -42,12 +43,15 @@ public interface AndroidNotificationManager { String ONGOING_CHANNEL_OLD_ID = "zForegroundService"; String ONGOING_CHANNEL_ID = "zForegroundService2"; String REMINDER_CHANNEL_ID = "zSignInReminder"; + String HOTSPOT_CHANNEL_ID = "zHotspot"; + // This channel is no longer used - keep the ID so we can remove the // channel from existing installations String FAILURE_CHANNEL_ID = "zStartupFailure"; // Actions for pending intents String ACTION_DISMISS_REMINDER = "dismissReminder"; + String ACTION_STOP_HOTSPOT = "stopHotspot"; Notification getForegroundNotification(); @@ -96,4 +100,8 @@ public interface AndroidNotificationManager { void blockAllBlogPostNotifications(); void unblockAllBlogPostNotifications(); + + void showHotspotNotification(); + + void clearHotspotNotification(); } diff --git a/briar-android/src/main/res/drawable-anydpi-v24/notification_hotspot.xml b/briar-android/src/main/res/drawable-anydpi-v24/notification_hotspot.xml new file mode 100644 index 000000000..e1535c8e4 --- /dev/null +++ b/briar-android/src/main/res/drawable-anydpi-v24/notification_hotspot.xml @@ -0,0 +1,16 @@ + + + + + diff --git a/briar-android/src/main/res/drawable-hdpi/notification_hotspot.png b/briar-android/src/main/res/drawable-hdpi/notification_hotspot.png new file mode 100644 index 000000000..c46f4f0f0 Binary files /dev/null and b/briar-android/src/main/res/drawable-hdpi/notification_hotspot.png differ diff --git a/briar-android/src/main/res/drawable-mdpi/notification_hotspot.png b/briar-android/src/main/res/drawable-mdpi/notification_hotspot.png new file mode 100644 index 000000000..7b925f098 Binary files /dev/null and b/briar-android/src/main/res/drawable-mdpi/notification_hotspot.png differ diff --git a/briar-android/src/main/res/drawable-xhdpi/notification_hotspot.png b/briar-android/src/main/res/drawable-xhdpi/notification_hotspot.png new file mode 100644 index 000000000..59ce89496 Binary files /dev/null and b/briar-android/src/main/res/drawable-xhdpi/notification_hotspot.png differ diff --git a/briar-android/src/main/res/drawable-xxhdpi/notification_hotspot.png b/briar-android/src/main/res/drawable-xxhdpi/notification_hotspot.png new file mode 100644 index 000000000..57c39a2e3 Binary files /dev/null and b/briar-android/src/main/res/drawable-xxhdpi/notification_hotspot.png differ diff --git a/briar-android/src/main/res/drawable/ic_circle_small.xml b/briar-android/src/main/res/drawable/ic_circle_small.xml new file mode 100644 index 000000000..97a7c4689 --- /dev/null +++ b/briar-android/src/main/res/drawable/ic_circle_small.xml @@ -0,0 +1,10 @@ + + + diff --git a/briar-android/src/main/res/drawable/ic_portable_wifi_off.xml b/briar-android/src/main/res/drawable/ic_portable_wifi_off.xml new file mode 100644 index 000000000..d214583a2 --- /dev/null +++ b/briar-android/src/main/res/drawable/ic_portable_wifi_off.xml @@ -0,0 +1,10 @@ + + + diff --git a/briar-android/src/main/res/drawable/ic_qr_code.xml b/briar-android/src/main/res/drawable/ic_qr_code.xml new file mode 100644 index 000000000..55b5bb0a7 --- /dev/null +++ b/briar-android/src/main/res/drawable/ic_qr_code.xml @@ -0,0 +1,40 @@ + + + + + + + + + + + + + diff --git a/briar-android/src/main/res/drawable/ic_settings_share.xml b/briar-android/src/main/res/drawable/ic_settings_share.xml new file mode 100644 index 000000000..79baee417 --- /dev/null +++ b/briar-android/src/main/res/drawable/ic_settings_share.xml @@ -0,0 +1,12 @@ + + + diff --git a/briar-android/src/main/res/drawable/ic_wifi_tethering.xml b/briar-android/src/main/res/drawable/ic_wifi_tethering.xml new file mode 100644 index 000000000..3a3a02798 --- /dev/null +++ b/briar-android/src/main/res/drawable/ic_wifi_tethering.xml @@ -0,0 +1,10 @@ + + + diff --git a/briar-android/src/main/res/layout/fragment_error.xml b/briar-android/src/main/res/layout/fragment_error.xml index bd663a9e3..ecb2bfe88 100644 --- a/briar-android/src/main/res/layout/fragment_error.xml +++ b/briar-android/src/main/res/layout/fragment_error.xml @@ -9,11 +9,7 @@ android:id="@+id/errorIcon" android:layout_width="128dp" android:layout_height="128dp" - android:layout_marginStart="8dp" - android:layout_marginLeft="8dp" - android:layout_marginTop="8dp" - android:layout_marginEnd="8dp" - android:layout_marginRight="8dp" + android:layout_margin="8dp" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" @@ -25,11 +21,7 @@ android:id="@+id/errorTitle" android:layout_width="wrap_content" android:layout_height="wrap_content" - android:layout_marginStart="8dp" - android:layout_marginLeft="8dp" - android:layout_marginTop="8dp" - android:layout_marginEnd="8dp" - android:layout_marginRight="8dp" + android:layout_margin="8dp" android:text="@string/sorry" android:textSize="@dimen/text_size_xlarge" app:layout_constraintEnd_toEndOf="parent" @@ -49,6 +41,6 @@ app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@+id/errorTitle" - tools:text="@string/qr_code_unsupported" /> + tools:text="@string/startup_failed_service_error" /> diff --git a/briar-android/src/main/res/layout/fragment_hotspot_error.xml b/briar-android/src/main/res/layout/fragment_hotspot_error.xml new file mode 100644 index 000000000..2bf918c5b --- /dev/null +++ b/briar-android/src/main/res/layout/fragment_hotspot_error.xml @@ -0,0 +1,80 @@ + + + + + + + + + + + +